diff --git a/.github/actions/rust-setup/action.yml b/.github/actions/rust-setup/action.yml index b7289679a..89be97fc4 100644 --- a/.github/actions/rust-setup/action.yml +++ b/.github/actions/rust-setup/action.yml @@ -118,7 +118,7 @@ runs: # them were reviewed rather than trusted -- runner updates, patch-release # table entries, a cross-device-copy fix and checkout bumps. - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # v1 + uses: dtolnay/rust-toolchain@7e38f4b43b4db5c8dd498af069a4f6196df1d067 # v1 with: toolchain: ${{ steps.resolve.outputs.toolchain }} targets: ${{ inputs.targets }} diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 3ca59a49d..d4a1a0059 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -13,47 +13,41 @@ updates: labels: - "dependencies" - "rust" + # The prefix carries no scope: `include: "scope"` appends `(deps)` (or + # `(deps-dev)`) itself, so a `chore(deps)` prefix titled every PR + # `chore(deps)(deps): ...` until v3.0.1. commit-message: - prefix: "chore(deps)" + prefix: "chore" include: "scope" - reviewers: - - "doublegate" + # No `reviewers:` key: GitHub retired it in 2025 in favour of CODEOWNERS + # (github.blog changelog 2025-08-08), and `.github/CODEOWNERS` assigns + # @doublegate to every path. Removed in v3.0.1. assignees: - "doublegate" - # HOLD the egui tier. A comment in `Cargo.toml` explains WHY the pin exists, - # but a comment does not reach Dependabot -- it would keep re-opening the - # same three PRs every Monday, and the risk is not the noise, it is that the - # twentieth identical PR gets merged on the assumption it is routine. - # - # `egui-winit` 0.36.1 does not compile for `wasm32-unknown-unknown`, and - # RustyNES ships a wasm demo: - # - # error[E0407]: method `bytes` is not a member of trait `egui::DroppedFile` - # - # egui 0.36 split `DroppedFile` by target (`bytes_async` on wasm, `bytes` on - # native) while egui-winit's `NativeFile` impl has no cfg gate, so on wasm it - # implements a method the trait does not declare and omits the one it does. - # `wgpu` is held with them only because `egui-wgpu` 0.35 pins it. - # - # REMOVE ALL FOUR ENTRIES once upstream ships the fix -- this is a hold, not - # a policy. Re-check by bumping and running both wasm clippy invocations. - ignore: - - dependency-name: "egui" - versions: [">=0.36"] - - dependency-name: "egui-wgpu" - versions: [">=0.36"] - - dependency-name: "egui-winit" - versions: [">=0.36"] - - dependency-name: "wgpu" - versions: [">=30"] - # `naga` is a DIRECT dependency of `rustynes-frontend` (WGSL validation), - # not merely a wgpu transitive, so Dependabot would propose 30 on its own - # and the hold would be broken from a direction the four entries above do - # not cover. Caught in review on the v2.6.3 refresh. - - dependency-name: "naga" - versions: [">=30"] - # Group minor and patch updates together + # From 2026-09-12 to 2026-09-28 an `ignore` block here held egui, + # egui-wgpu, egui-winit (`>=0.36`), wgpu and naga (`>=30`) at the 0.35 tier, + # because egui-winit 0.36 did not compile for wasm32. The tier then moved to + # 0.36 / wgpu 30 with egui-winit vendored (see `Cargo.toml`), and the hold + # stayed behind until v3.0.1 -- silently blocking every later egui and wgpu + # release. The coupling it protected is real, so it is now a GROUP instead. groups: + # ONE PR FOR THE WHOLE TIER. `Cargo.toml` says these five move together: + # `egui-wgpu` pins one wgpu major, a graph holding two wgpu majors fails + # every device/queue handoff, and `naga` is a DIRECT dependency of + # `rustynes-frontend` (WGSL validation) that must match wgpu's. Listed + # FIRST, with no `update-types`, because Dependabot puts a dependency in + # the first group it matches -- below the catch-all groups, a minor bump + # of one of these would land alone in `production-dependencies`. Before + # merging one: egui-winit is vendored (`vendor/egui-winit/VENDORED.md`), + # and a bump past 0.36.2 bypasses that patch, so run both wasm clippy gates. + egui-wgpu-tier: + patterns: + - "egui" + - "egui-wgpu" + - "egui-winit" + - "wgpu" + - "naga" + # Group minor and patch updates together development-dependencies: dependency-type: "development" update-types: @@ -119,7 +113,7 @@ updates: - "dependencies" - "android" commit-message: - prefix: "chore(deps)" + prefix: "chore" include: "scope" # `crates/rustynes-cosim` is EXCLUDED from the workspace on purpose (cargo @@ -142,7 +136,7 @@ updates: - "dependencies" - "rust" commit-message: - prefix: "chore(deps)" + prefix: "chore" include: "scope" # Maintain GitHub Actions @@ -159,7 +153,5 @@ updates: - "github-actions" commit-message: prefix: "chore(ci)" - reviewers: - - "doublegate" assignees: - "doublegate" diff --git a/.github/release-notes/v2.6.7.md b/.github/release-notes/v2.6.7.md index 4a6588808..f0f0c25b2 100644 --- a/.github/release-notes/v2.6.7.md +++ b/.github/release-notes/v2.6.7.md @@ -6,6 +6,8 @@ A detent is the notch that holds a mechanism at an exact position and makes that **The emulation core is unchanged.** AccuracyCoin 141/141 (RAM decoder) and nestest 0-diff hold by construction; the work is in the sibling repository and in the release machinery. +> Correction (v3.0.1): the sentence above is wrong. The emulation core changed in one place in this release, the taken-branch NMI poll in `Cpu::handle_interrupts` (`skip_irq_sample_q`, `CPU_SNAPSHOT_VERSION` 3 to 4; see "An interrupt polled on a cycle the documentation forbids" below), and AccuracyCoin 141/141 and nestest 0-diff were re-run against it rather than holding by construction. + ## The gate | # | criterion | result | diff --git a/.github/release-notes/v3.0.1.md b/.github/release-notes/v3.0.1.md new file mode 100644 index 000000000..3f7cee688 --- /dev/null +++ b/.github/release-notes/v3.0.1.md @@ -0,0 +1,52 @@ +# RustyNES v3.0.1 — "Mortar" + +A maintenance release on v3.0.0. It fixes one game's graphics, reaches the last untested exception of the MMC3 timing rule in the MiSTer core (and fixes the one-cycle defect that found), moves the toolchain and every dependency to its newest release, answers every bot review left unanswered since PR #1, settles two provenance questions, and writes the plan to v4.0.0. **Movies recorded with v3.0.0, and netplay peers running it, are refused**: the emulation epoch rises to 2. Save states are unaffected. + +## Breaking change + +- **Movies and netplay from v3.0.0 are refused.** The mapper 45 fix below changes what *Famicom Yarou Vol.1* produces, so `EMULATION_EPOCH` rises from 1 to 2 ([ADR 0045](https://github.com/doublegate/RustyNES/blob/main/docs/adr/0045-a-core-timing-epoch-guards-movies-and-netplay.md)). The refusal names both epochs. Save states load as before. + +## Fixed + +- ***Famicom Yarou Vol.1 7-in-1* draws its menu.** Mapper 45 (GA23C) addresses a cartridge's CHR-RAM unbanked, as the board does; the emulator had banked it like CHR-ROM and drew noise (T-GA23C-CHRRAM). +- **The MiSTer core's MMC3 timing, one cycle on odd frames.** v3.0.0's dot-0 rule for the background's A12 had an exception for the dot the odd-frame skip replaces, and nothing ever reached it. A new generated test ROM does, and found that a `$2001` write taking effect one dot late still applied the rule, so the MMC3 interrupt came one CPU cycle early. The rule now asks whether cycle 0 was rendering. The core and the emulator agree on all 2,978,055 cycles of the new test, and a new comparison of the cycle on which each interrupt rises catches the exception's mutant, which the bus comparison alone could not. +- **Every unanswered bot review, back to PR #1, answered.** 290 unanswered review threads, review-body findings and Antigravity reviews across both repositories got 473 verdicts; the 80 still valid were fixed. Among them: the Bisqwit NTSC filter kept showing the last game frame after a ROM was closed; a mapper-0 override saved from the ROM Database panel vanished on restart; the release tooling could exit 0 on a stale lockfile; three release audits could pass on prose they should fail; and Dependabot's egui holds blocked every future egui and wgpu update (now a group that moves them together). + +## Provenance + +- **The shared Bisqwit NTSC pass is recorded as derived.** Its documentation called it an independent implementation, but it is a generated copy of the desktop filter, whose tables were long recorded as ported from Bisqwit's C via Mesen2. It now carries the same attribution, and the audit lost the exception that hid it. +- **The TriCNES source moved out of the repository.** TriCNES (MIT, by the AccuracyCoin author) stays the one reference whose source may be consulted, for AccuracyCoin work and always attributed. The committed cross-diff evidence stays. +- **A softened comment in the Sunsoft 5B mixer says "derived from" again.** + +## Toolchain and dependencies + +- **Rust 1.99, everywhere.** The libretro buildbot moves too: the release first held it on 1.96 because its build image passed a flag Rust 1.97 rejects, then found the image had dropped it, and a test branch built all 15 buildbot jobs on 1.99, Apple included. CI now fails if the two toolchains ever differ. +- **Every dependency at its newest release**: crates, GitHub Actions (macOS jobs move to `macos-15`), Android (`cargo-ndk` 4, NDK r30), the web build (wasm-opt now pinned), the documentation build, and Docker images (Rust 1.99, Debian 13). + +## The MiSTer core + +- **Release candidate, not hardware-verified.** No hardware has run either bitstream. +- **The co-simulation ladder is 200 passed, 0 failed, 1 expected failure on-die and 201 passed, 0 failed, 1 expected failure off-die**, each from one run of a frozen tree, with nothing skipped. The one new gate is the odd-frame A12 test above. +- **New bitstreams, because the fix is RTL.** Both builds are compiled at fitter seed 2, chosen from eight seeds swept on one build date (261007), every one of which closes on both builds. Each was compiled twice to the same bytes: + - on-die `RustyNES_MiSTer-v3.0.1.rbf`, md5 `7e81a71869760b35a2fd04e6309c5c39` (timing margin +0.448 ns setup, +0.113 ns hold); + - off-die `RustyNES_MiSTer-v3.0.1-offdie.rbf`, md5 `88d1dfa53a94996a92c0f768b3be3039` (+0.401 / +0.096 ns; the SDRAM read +0.447 / +1.184 ns, assuming zero board delay). +- The bitstreams carry the sweep's build date, which may be earlier than the release date. + +## The road to v4.0.0 + +The plan from v3.1.0 to v4.0.0 is written ([`to-dos/plans/v3.1-to-v4.0-line-plan.md`](https://github.com/doublegate/RustyNES/blob/main/to-dos/plans/v3.1-to-v4.0-line-plan.md)), from 29 maintainer decisions. v4.0.0 makes the remaining public enums `#[non_exhaustive]` and brings the MiSTer core to feature parity. The hardware-verification release (the SuperStation One board session and the mobile device run) comes at the end of the v3.9.x line, so it tests the near-final core. + +## Verification + +- `--features test-roms`: 3,234 passed, 0 failed, 11 ignored. +- The local commercial suites: `external_real_games` 60/0, `external_extended` 137/0, `external_coverage` 6/0 over every staged ROM. The one moved baseline is *Famicom Yarou Vol.1*, which now draws its menu. +- AccuracyCoin 144/144, nestest 0-diff. +- The libretro buildbot: all 15 jobs on Rust 1.99 before the pin was lifted. + +## Install + +- Download the pre-built binaries for Linux, macOS, and Windows below. +- The MiSTer core bitstreams are attached below, as a release candidate, not hardware-verified. The on-die build is `RustyNES_MiSTer-v3.0.1.rbf`, also attached under its datecoded name `RustyNES_20261007.rbf`, and the off-die build is `RustyNES_MiSTer-v3.0.1-offdie.rbf`. +- The WebAssembly build is live at [doublegate.github.io/RustyNES](https://doublegate.github.io/RustyNES/). +- The RetroArch core is in RetroArch's Online Updater on the platforms the libretro buildbot publishes to. +- Licensed under GPL-3.0-or-later. diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 61be99f66..cb9f0f713 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -60,6 +60,12 @@ concurrency: env: CARGO_TERM_COLOR: always + # v3.0.1: NDK r30, the newest stable (`ndk;30.0.16248370` in Google's SDK + # repository). The runner image ships r29 as its newest, so every job + # installs this one with `sdkmanager` in its "Resolve NDK" step. r30's + # changelog lists a newer clang, simpleperf prebuilts and sysroots up to + # API 37; nothing that touches a `--platform 26` build. + NDK_VERSION: "30.0.16248370" jobs: cross-build: @@ -103,7 +109,7 @@ jobs: - 'crates/rustynes-mobile/**' - '.github/workflows/android.yml' - # The project toolchain (rust-toolchain.toml = 1.96) plus the two shipped + # The project toolchain (rust-toolchain.toml = 1.99) plus the two shipped # Android targets (arm64 ships; x86_64 is the emulator/CI ABI), through # the shared composite. A bare `rustup target add` made rustup # AUTO-INSTALL the pinned toolchain first, which rustup now deprecates @@ -122,19 +128,29 @@ jobs: # `taiki-e/install-action` knows, which is why this is the caching form. - uses: taiki-e/cache-cargo-install-action@v3 with: - tool: cargo-ndk@3 + tool: cargo-ndk@4 # ubuntu-26.04 ships the Android SDK + an NDK; resolve its path so # cargo-ndk finds the toolchain. (We pin a recent NDK; r27+ aligns .so to # 16 KB by default — a Play requirement for Android 15+.) # Both variables, to the same NDK. The runner image sets ANDROID_NDK_ROOT - # to its default NDK (27.x) while this job selects the latest (29.x) via + # to its default NDK (27.x) while this job selects its own (r30, above) via # ANDROID_NDK_HOME, and cargo-ndk warned on every build that the two # disagree -- a real ambiguity about which toolchain linked the library. - name: Resolve NDK run: | - echo "ANDROID_NDK_HOME=$ANDROID_NDK_LATEST_HOME" >> "$GITHUB_ENV" - echo "ANDROID_NDK_ROOT=$ANDROID_NDK_LATEST_HOME" >> "$GITHUB_ENV" + set -euo pipefail + sdk="${ANDROID_SDK_ROOT:-$ANDROID_HOME}" + # `yes` answers the licence prompt. Under `pipefail` its own exit + # status (EPIPE / SIGPIPE once sdkmanager closes the pipe, 141 here) + # failed the step even when the install succeeded, so it is absorbed; + # sdkmanager's status, and the clang check below, still decide. + (yes || true) | "$sdk/cmdline-tools/latest/bin/sdkmanager" --install "ndk;$NDK_VERSION" > /dev/null + ndk="$sdk/ndk/$NDK_VERSION" + test -x "$ndk/toolchains/llvm/prebuilt/linux-x86_64/bin/clang" \ + || { echo "::error::NDK $NDK_VERSION did not install at $ndk"; exit 1; } + echo "ANDROID_NDK_HOME=$ndk" >> "$GITHUB_ENV" + echo "ANDROID_NDK_ROOT=$ndk" >> "$GITHUB_ENV" - name: Cross-compile mobile + android (arm64 + x86_64) run: | @@ -198,15 +214,29 @@ jobs: cache-key: android-ndk - uses: taiki-e/cache-cargo-install-action@v3 with: - tool: cargo-ndk@3 + tool: cargo-ndk@4 + # v3.0.1: Temurin 25, the newest LTS (was 17). This is the JDK that RUNS + # Gradle; the bytecode target stays JVM 17 (`jvmTarget` and + # `sourceCompatibility` in app/build.gradle.kts), which is AGP 9.4's + # floor. Gradle 9.8 supports running on Java 25 (9.1.0 and later). - uses: actions/setup-java@v6 with: distribution: temurin - java-version: '17' + java-version: '25' - name: Resolve NDK run: | - echo "ANDROID_NDK_HOME=$ANDROID_NDK_LATEST_HOME" >> "$GITHUB_ENV" - echo "ANDROID_NDK_ROOT=$ANDROID_NDK_LATEST_HOME" >> "$GITHUB_ENV" + set -euo pipefail + sdk="${ANDROID_SDK_ROOT:-$ANDROID_HOME}" + # `yes` answers the licence prompt. Under `pipefail` its own exit + # status (EPIPE / SIGPIPE once sdkmanager closes the pipe, 141 here) + # failed the step even when the install succeeded, so it is absorbed; + # sdkmanager's status, and the clang check below, still decide. + (yes || true) | "$sdk/cmdline-tools/latest/bin/sdkmanager" --install "ndk;$NDK_VERSION" > /dev/null + ndk="$sdk/ndk/$NDK_VERSION" + test -x "$ndk/toolchains/llvm/prebuilt/linux-x86_64/bin/clang" \ + || { echo "::error::NDK $NDK_VERSION did not install at $ndk"; exit 1; } + echo "ANDROID_NDK_HOME=$ndk" >> "$GITHUB_ENV" + echo "ANDROID_NDK_ROOT=$ndk" >> "$GITHUB_ENV" - uses: gradle/actions/setup-gradle@v6.4.0 with: cache-provider: basic @@ -254,19 +284,33 @@ jobs: # `taiki-e/install-action` knows, which is why this is the caching form. - uses: taiki-e/cache-cargo-install-action@v3 with: - tool: cargo-ndk@3 + tool: cargo-ndk@4 + # v3.0.1: Temurin 25, the newest LTS (was 17). This is the JDK that RUNS + # Gradle; the bytecode target stays JVM 17 (`jvmTarget` and + # `sourceCompatibility` in app/build.gradle.kts), which is AGP 9.4's + # floor. Gradle 9.8 supports running on Java 25 (9.1.0 and later). - uses: actions/setup-java@v6 with: distribution: temurin - java-version: '17' + java-version: '25' # Both variables, to the same NDK. The runner image sets ANDROID_NDK_ROOT - # to its default NDK (27.x) while this job selects the latest (29.x) via + # to its default NDK (27.x) while this job selects its own (r30, above) via # ANDROID_NDK_HOME, and cargo-ndk warned on every build that the two # disagree -- a real ambiguity about which toolchain linked the library. - name: Resolve NDK run: | - echo "ANDROID_NDK_HOME=$ANDROID_NDK_LATEST_HOME" >> "$GITHUB_ENV" - echo "ANDROID_NDK_ROOT=$ANDROID_NDK_LATEST_HOME" >> "$GITHUB_ENV" + set -euo pipefail + sdk="${ANDROID_SDK_ROOT:-$ANDROID_HOME}" + # `yes` answers the licence prompt. Under `pipefail` its own exit + # status (EPIPE / SIGPIPE once sdkmanager closes the pipe, 141 here) + # failed the step even when the install succeeded, so it is absorbed; + # sdkmanager's status, and the clang check below, still decide. + (yes || true) | "$sdk/cmdline-tools/latest/bin/sdkmanager" --install "ndk;$NDK_VERSION" > /dev/null + ndk="$sdk/ndk/$NDK_VERSION" + test -x "$ndk/toolchains/llvm/prebuilt/linux-x86_64/bin/clang" \ + || { echo "::error::NDK $NDK_VERSION did not install at $ndk"; exit 1; } + echo "ANDROID_NDK_HOME=$ndk" >> "$GITHUB_ENV" + echo "ANDROID_NDK_ROOT=$ndk" >> "$GITHUB_ENV" - uses: gradle/actions/setup-gradle@v6.4.0 with: # v6 extracted the default ("enhanced") Gradle User Home cache into the diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e1c2dbe1a..5c0ac339a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -220,9 +220,17 @@ jobs: - '.github/workflows/ci.yml' - '.github/actions/**' - '.github/scripts/**' - # rustynes-libretro -> core, cpu, ppu, apu, mappers + # rustynes-libretro -> core, cpu, ppu, apu, mappers, gamedb, plus the + # vendored rust-libretro-sys. `.gitlab-ci.yml` too (v3.0.1): it holds + # the buildbot's RUSTUP_TOOLCHAIN, which this job's first step checks + # against rust-toolchain.toml -- a PR changing only that file must + # still run the check (CodeRabbit on #592). gamedb and vendor/ were + # missing: both are compiled into the core. libretro: - *shared + - '.gitlab-ci.yml' + - 'vendor/**' + - 'crates/rustynes-gamedb/**' - 'crates/rustynes-libretro/**' - 'crates/rustynes-core/**' - 'crates/rustynes-cpu/**' @@ -244,11 +252,11 @@ jobs: # spin-ups vs three separate jobs. fmt runs first (it compiles nothing), so # a formatting slip still fails in seconds. # - # Pinned to the project toolchain (rust-toolchain.toml = 1.96.0), NOT a + # Pinned to the project toolchain (rust-toolchain.toml = 1.99.0), NOT a # floating `stable`: clippy adds lints every release, so `stable` makes the # lint gate drift and break on a toolchain bump. Pinning ALSO makes the # rustdoc gate match local `cargo doc` exactly (rust-toolchain.toml pins the - # local compiler to 1.96 too) — the stable-vs-1.96 mismatch previously let a + # local compiler too) — a stable-vs-pin mismatch previously let a # broken-intra-doc-link slip past local and only fail in CI. # # Compute ONE "full-run" flag that gates both the OS matrix and the expensive @@ -361,6 +369,13 @@ jobs: --all-targets -- -D warnings - name: "test (excluded crate: rustynes-cosim)" run: cargo test --manifest-path crates/rustynes-cosim/Cargo.toml + # v3.0.1: the workspace rustdoc step above does not reach the excluded + # crate either, and a public doc linking a private constant sat there + # unseen until a review fix ran it by hand. + - name: "rustdoc (excluded crate: rustynes-cosim)" + env: + RUSTDOCFLAGS: "-D warnings" + run: cargo doc --no-deps --manifest-path crates/rustynes-cosim/Cargo.toml # ... and again WITH `ppu-state-trace`, because that crate's # `state_trace_records_carry_their_cpu_cycle` test is itself gated on the # feature and so ran NOWHERE in CI. It exists to catch a field that is @@ -493,10 +508,11 @@ jobs: --features commercial-roms,debug-hooks -- -D warnings # Cross-platform behaviour: full `cargo test` on stable across the three - # shipped OSes. The previous ubuntu / 1.96 MSRV *test* entry is dropped — the + # shipped OSes. The previous ubuntu MSRV *test* entry is dropped — the # `lint` job above already compiles the whole workspace (`clippy - # --all-targets`) on 1.96, which IS the MSRV compile gate, and the core's - # determinism makes "tests pass on 1.96" equivalent to "tests pass on stable". + # --all-targets`) on the pin, which IS the MSRV compile gate (one floor for + # every crate since v3.0.1; `libretro-cross` builds the buildbot's), and the core's + # determinism makes "tests pass on the pin" equivalent to "tests pass on stable". test: name: test (${{ matrix.os }}) # Gate the matrix on the fast fmt+clippy+rustdoc job (a lint slip fails in @@ -587,7 +603,9 @@ jobs: strategy: fail-fast: false matrix: - os: [macos-14, windows-latest] + # macos-15, not macos-14: the 14 image is deprecated (runner-images + # #13518, brownouts from 2026-10-05, unsupported from 2026-11-02). + os: [macos-15, windows-latest] steps: - uses: actions/checkout@v7 with: @@ -821,11 +839,52 @@ jobs: - uses: actions/checkout@v7 with: persist-credentials: false - # No `toolchain:` input anywhere in this repo: `rust-setup` resolves it - # from `rust-toolchain.toml`, so this job rehearses the buildbot on the - # exact channel the buildbot uses. + # This job rehearses the libretro buildbot, so it runs the buildbot's + # toolchain, read from `.gitlab-ci.yml`'s `RUSTUP_TOOLCHAIN`, and it + # FAILS if that differs from `rust-toolchain.toml`'s `channel`. The two + # must move together: v3.0.1 briefly held the buildbot on 1.96.0 (the + # build image's `-C ar`, a hard error from 1.97) and lifted the hold the + # same release once the image dropped the flag (pipeline 119614, 15/15 + # on 1.99.0). The check keeps a drift from being found on the buildbot + # after merge. `.github/` still carries no version literal + # (`docs/agents/ci-and-release.md`). Both parsers are table-scoped and + # fail-closed, like `rust-setup`'s own resolver. + - name: Resolve the buildbot toolchain + id: buildbot + shell: bash + run: | + set -euo pipefail + tc="$(awk ' + /^\.core-defs:/ { in_defs = 1; next } + in_defs && /^[^[:space:]#]/ { in_defs = 0 } + in_defs && $1 == "RUSTUP_TOOLCHAIN:" { + v = $2; gsub(/"/, "", v); print v; exit + } + ' .gitlab-ci.yml)" + if [ -z "$tc" ]; then + echo "::error::No RUSTUP_TOOLCHAIN under .core-defs in .gitlab-ci.yml" + exit 1 + fi + pin="$(awk ' + /^\[/ { in_tc = ($0 == "[toolchain]"); next } + in_tc && $1 == "channel" { + v = $3; gsub(/"/, "", v); print v; exit + } + ' rust-toolchain.toml)" + if [ -z "$pin" ]; then + echo "::error::No channel under [toolchain] in rust-toolchain.toml" + exit 1 + fi + if [ "$tc" != "$pin" ]; then + echo "::error::.gitlab-ci.yml RUSTUP_TOOLCHAIN ($tc) differs from rust-toolchain.toml channel ($pin); move them together" + exit 1 + fi + echo "Buildbot toolchain: $tc (matches rust-toolchain.toml)" + echo "toolchain=$tc" >> "$GITHUB_OUTPUT" + echo "RUSTUP_TOOLCHAIN=$tc" >> "$GITHUB_ENV" - uses: ./.github/actions/rust-setup with: + toolchain: ${{ steps.buildbot.outputs.toolchain }} apt: "false" cache-key: libretro-${{ matrix.target }} # Point bindgen at the NDK sysroot, which is what `cargo ndk` does for us @@ -900,17 +959,19 @@ jobs: # Deliberately NOT the composite action's `targets:` input. That routes # through `dtolnay/rust-toolchain`, which installs the target for the # channel IT pins (stable) — while `rust-toolchain.toml` then switches - # cargo to 1.96.0, leaving the target missing on the toolchain that + # cargo to the pinned toolchain, leaving the target missing on the toolchain that # actually builds. `no_std` / `wasm` escape this only because their two # targets are listed in `rust-toolchain.toml`. Running `rustup target # add` from inside the checkout resolves that file first, so the target - # lands on 1.96.0 — the identical one-liner `.gitlab-ci.yml` runs, which + # lands on that toolchain — the identical one-liner `.gitlab-ci.yml` runs, which # keeps this job an honest rehearsal of the buildbot rather than a # differently-configured lookalike. - - run: rustup target add ${{ matrix.target }} + - run: rustup toolchain install "$RUSTUP_TOOLCHAIN" --profile minimal --target ${{ matrix.target }} # Until this job existed there was ZERO libretro coverage in GitHub - # Actions, so breakage only surfaced on libretro's own GitLab buildbot — - # where we can't push, can't re-run, and turnaround is days. Pipeline + # Actions, so breakage only surfaced on libretro's own GitLab buildbot, + # whose job logs need a login and whose turnaround was days (it does build + # every pushed branch, so a release branch is tested there too; see + # docs/agents/libretro.md). Pipeline # #91899 failed 9 of 10 jobs on problems this job reproduces in seconds: # a missing cross target (see `rust-toolchain.toml`) and an upstream # `rust-libretro` MinGW-ABI bug (see `.cargo/config.toml`). @@ -1004,7 +1065,7 @@ jobs: toolchain: nightly apt: "false" cache-key: fuzz - - uses: taiki-e/install-action@v2.87.21 + - uses: taiki-e/install-action@v2.87.26 with: tool: cargo-fuzz - name: Build and run every fuzz target @@ -1068,7 +1129,7 @@ jobs: with: components: llvm-tools-preview cache-key: coverage - - uses: taiki-e/install-action@v2.87.21 + - uses: taiki-e/install-action@v2.87.26 with: tool: cargo-llvm-cov - run: cargo llvm-cov --workspace --lib --summary-only --fail-under-lines 70 diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index 8f2afd887..668cf8364 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -129,7 +129,7 @@ jobs: echo "::warning title=Xcode 26 not on runner::Building with $(xcodebuild -version | head -1). App Store uploads require Xcode 26 / the iOS 26 SDK from 2026-04-28 (App Store submission floor); ensure the release runner image has Xcode 26 before the v2.1.0 store launch." fi - # The project toolchain (rust-toolchain.toml = 1.96) plus the iOS targets: + # The project toolchain (rust-toolchain.toml = 1.99) plus the iOS targets: # the device ABI + both simulator ABIs (the sim slice is lipo'd from the # Apple-silicon + Intel sim builds). - name: Install iOS Rust targets diff --git a/.github/workflows/pgo.yml b/.github/workflows/pgo.yml index 679ec3db5..e70a55457 100644 --- a/.github/workflows/pgo.yml +++ b/.github/workflows/pgo.yml @@ -108,7 +108,7 @@ jobs: with: persist-credentials: false - # Pinned to the project toolchain (rust-toolchain.toml = 1.96.0), matching + # Pinned to the project toolchain (rust-toolchain.toml = 1.99.0), matching # the other jobs. PGO needs the llvm-tools-preview component (profdata # merge) and the wgpu/winit/cpal apt deps (the optimized rebuild links the # full `rustynes-frontend` binary). diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4a6e8d05c..0167b2ed1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -77,7 +77,10 @@ jobs: # a shrinking cohort, the release matrix ships aarch64 macOS only; # Intel-Mac users build from source (`cargo build --release -p # rustynes-frontend`). See docs/adr/0009-drop-x86_64-darwin-release-target.md. - - os: macos-14 + # v3.0.1: macos-14 is deprecated (runner-images#13518: brownouts + # from 2026-10-05, unsupported from 2026-11-02); macos-15 is the + # next Apple-silicon image, still aarch64. + - os: macos-15 target: aarch64-apple-darwin archive_ext: tar.gz bin_name: rustynes diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 495dd9a8e..42dc496c6 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -76,7 +76,7 @@ jobs: # Reason 2 was an ACTIVE constraint until the v1.3.0 pin bump (1.86 vs # cargo-audit's 1.88); it is slack today and can bind again after an MSRV # change in either direction, so it is kept rather than deleted as history. - - uses: taiki-e/install-action@v2.87.21 + - uses: taiki-e/install-action@v2.87.26 with: tool: cargo-audit - run: cargo audit @@ -92,7 +92,7 @@ jobs: # Prebuilt binary for the same two reasons as the audit job above: it is # much faster than compiling cargo-deny, and it keeps the security gate # independent of this repo's rustc pin. Policy lives in `deny.toml`. - - uses: taiki-e/install-action@v2.87.21 + - uses: taiki-e/install-action@v2.87.26 with: tool: cargo-deny - run: cargo deny check diff --git a/.github/workflows/toolchain-canary.yml b/.github/workflows/toolchain-canary.yml index 0072364a1..2792f9869 100644 --- a/.github/workflows/toolchain-canary.yml +++ b/.github/workflows/toolchain-canary.yml @@ -2,11 +2,13 @@ name: Toolchain canary # Early warning for toolchain drift, NOT a gate. # -# The project pins Rust 1.96 in rust-toolchain.toml and will stay there until -# the libretro buildbot image stops passing `-C ar`, which breaks the Apple jobs -# on 1.97 and later (maintainer decision, 2026-09-28). A pin that holds for -# months hides every new lint and every deprecation until the day it moves, and -# then they all arrive at once. This workflow builds, lints and tests the +# The project pins a Rust release in rust-toolchain.toml (1.99.0 since v3.0.1; +# 1.96.0 before it, held from 2026-09-28 because the libretro buildbot image +# passed `-C ar`, a hard error from 1.97; the image dropped it on 2026-09-03 and +# the buildbot follows the pin again). +# A pin that holds for months hides every new lint and every deprecation until +# the day it moves, and then they all arrive at once: the move to 1.99 brought +# 70-odd new clippy findings in one go. This workflow builds, lints and tests the # workspace on the current stable and beta once a week, so the cost of moving # the pin is visible while it is still small. # diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml index 4dca4a801..c83f44921 100644 --- a/.github/workflows/web.yml +++ b/.github/workflows/web.yml @@ -19,7 +19,9 @@ name: Deploy Pages (demo + docs) # does NOT) so the size budget gates PRs without publishing a preview. # # trunk auto-downloads the wasm-bindgen CLI + wasm-opt versions pinned in -# crates/rustynes-frontend/web/Trunk.toml. +# crates/rustynes-frontend/web/Trunk.toml. (Until v3.0.1 only wasm-bindgen was +# pinned there, and this line overstated it: wasm-opt fell back to trunk's +# built-in default.) on: push: @@ -150,7 +152,7 @@ jobs: - name: Set up Python for MkDocs uses: actions/setup-python@v7 with: - python-version: "3.12" + python-version: "3.14" - name: Install the social-card image libraries (Cairo + Pango) # Required by the mkdocs-material `social` plugin's PNG renderer. diff --git a/.gitignore b/.gitignore index d1d887e93..3385cc027 100644 --- a/.gitignore +++ b/.gitignore @@ -73,13 +73,9 @@ Cargo.lock !/golden/** !/crates/rustynes-test-harness/golden/** -# ...but the vendored TriCNES harness is BUILT in place (`dotnet build -c -# Release`, per its README), and the re-inclusion above would otherwise sweep -# its output into the repo -- ~1.6 MB of bin/ + obj/ beside a tree whose own -# README promises "no build artifacts". The negation is what makes this -# necessary: without it the generic rules below would have covered these. -/crates/rustynes-test-harness/golden/tricnes/tricnes-harness-src/bin/ -/crates/rustynes-test-harness/golden/tricnes/tricnes-harness-src/obj/ +# (The vendored TriCNES harness used to be BUILT in place under golden/, and two +# negations here kept its bin/ + obj/ out. It left the repository in v3.0.1, so +# they went with it.) # --- Native build & FFI artifacts --- *.so @@ -265,8 +261,8 @@ flamegraph.svg # directory has been REMOVED from disk and stays ignored here as a firewall guard # so the copyleft source can never re-enter the working tree. Do NOT re-clone # reference-emulator source into the repo; implement hardware behavior from -# docs/test ROMs. (The MIT TriCNES source is instead deliberately vendored, with -# attribution, under crates/rustynes-test-harness/golden/tricnes/.) See +# docs/test ROMs. (The MIT TriCNES source was vendored until v3.0.1 and now lives +# outside the repository at ~/reference-oracles/; guardrails section 3a.) See # docs/ai-emulator-provenance-guardrails.md and the "MOST IMPORTANT RULE" of AGENTS.md. /ref-proj/ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 7cad53ad5..09b971896 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -30,12 +30,18 @@ # --------------------------------------------------------------------------- # Why every job carries a `before_script` (pipeline #91899 post-mortem) # --------------------------------------------------------------------------- +# (History, 2026-07-20. The mechanism still holds; two details moved since: +# the pin is whatever `rust-toolchain.toml` says (1.99.0 at v3.0.1), and since +# v3.0.1 each job runs `rustup toolchain install ${RUSTUP_TOOLCHAIN} --profile +# minimal --target ${RUST_TARGET}`, which installs the pinned toolchain WITH +# its target in one step, where this record says `rustup target add`.) +# # The first buildbot run passed only `libretro-build-linux-x64`; the other # nine died with `E0463: can't find crate for core`. Root cause is entirely # on our side, not the build images: # -# This repo pins its toolchain in `rust-toolchain.toml` (`channel = -# "1.96.0"`). The build image ships its own default toolchain with every +# This repo pinned its toolchain in `rust-toolchain.toml` (`channel = +# "1.96.0"` then). The build image ships its own default toolchain with every # libretro cross target pre-provisioned — but our pin makes rustup download # and install a SEPARATE, pristine 1.96.0 toolchain on the first `cargo` # invocation, and that toolchain carries only the host `rust-std` plus the @@ -70,12 +76,25 @@ # per-package `panic`, hence the profile environment variable; every job # here extends `.core-defs`, and GitLab merges `variables` across `extends`. CARGO_PROFILE_RELEASE_PANIC: "unwind" + # The toolchain every job here installs and builds with. It MUST equal + # `rust-toolchain.toml`'s `channel`: GitHub CI's `libretro-cross` job + # fails if the two differ. The variable outranks `rust-toolchain.toml`, + # and each job installs exactly this toolchain with its target. + # + # History: v3.0.1 first held this at 1.96.0 while the workspace moved to + # 1.99.0, because the libretro build image injected `-Car=...` into every + # Apple job and `-C ar` is a hard error from Rust 1.97. The image dropped + # that flag on 2026-09-03 (libretro-build-rust `841f3619`, "Remove -C usage + # as no longer supported by rust"), and a test branch built on 1.99.0 + # (pipeline 119614) passed all 15 jobs, the four Apple ones included, so + # the hold was lifted in the same release. + RUSTUP_TOOLCHAIN: "1.99.0" # Shared `before_script` for the jobs whose upstream template defines none # (Windows, Linux, and all four Android). See the post-mortem above. .rust-target-install: before_script: - - rustup target add ${RUST_TARGET} + - rustup toolchain install ${RUSTUP_TOOLCHAIN} --profile minimal --target ${RUST_TARGET} include: ################################## DESKTOPS ################################ @@ -143,7 +162,7 @@ libretro-build-windows-i686: RUST_TARGET: i686-pc-windows-gnu before_script: - !reference [.libretro-rust-windows-i686, before_script] - - rustup target add ${RUST_TARGET} + - rustup toolchain install ${RUSTUP_TOOLCHAIN} --profile minimal --target ${RUST_TARGET} # Linux 64-bit # `RUST_TARGET` is the host triple here, so the `rustup target add` is a @@ -169,7 +188,7 @@ libretro-build-linux-i686: RUST_TARGET: i686-unknown-linux-gnu before_script: - !reference [.libretro-rust-linux-i686, before_script] - - rustup target add ${RUST_TARGET} + - rustup toolchain install ${RUSTUP_TOOLCHAIN} --profile minimal --target ${RUST_TARGET} # Linux aarch64 # Cross-compiled from the same image as the x64 job. This job used to install @@ -186,7 +205,7 @@ libretro-build-linux-aarch64: RUST_TARGET: aarch64-unknown-linux-gnu before_script: - !reference [.libretro-rust-linux-aarch64, before_script] - - rustup target add ${RUST_TARGET} + - rustup toolchain install ${RUSTUP_TOOLCHAIN} --profile minimal --target ${RUST_TARGET} # macOS 64-bit # @@ -209,7 +228,7 @@ libretro-build-osx-x64: before_script: - !reference [.libretro-rust-osx-x86_64-default, before_script] - export SDKROOT=$OSX_SDKROOT - - rustup target add ${RUST_TARGET} + - rustup toolchain install ${RUSTUP_TOOLCHAIN} --profile minimal --target ${RUST_TARGET} # macOS ARM 64-bit libretro-build-osx-arm64: @@ -221,7 +240,7 @@ libretro-build-osx-arm64: before_script: - !reference [.libretro-rust-osx-arm64-default, before_script] - export SDKROOT=$OSX_SDKROOT - - rustup target add ${RUST_TARGET} + - rustup toolchain install ${RUSTUP_TOOLCHAIN} --profile minimal --target ${RUST_TARGET} ################################### CELLULAR ################################# # Android ARMv7a @@ -272,7 +291,7 @@ libretro-build-ios-arm64: RUST_TARGET: aarch64-apple-ios before_script: - !reference [.libretro-rust-ios-arm64-default, before_script] - - rustup target add ${RUST_TARGET} + - rustup toolchain install ${RUSTUP_TOOLCHAIN} --profile minimal --target ${RUST_TARGET} # tvOS # @@ -290,7 +309,7 @@ libretro-build-ios-arm64: # among them) and `cargo check --release -p rustynes-libretro --target # aarch64-apple-tvos` compiles the entire graph, bindgen included. # -# Restoring the shared script puts tvOS on the same pinned 1.96.0 as every +# Restoring the shared script puts tvOS on the same pinned toolchain as every # other job, and dissolves three separate workarounds this job used to need # rather than accumulating a fourth: # @@ -318,7 +337,8 @@ libretro-build-ios-arm64: # long-deprecated no-op became a hard error in Rust 1.97 (bisected: # 1.96.1 warns, 1.97.1 errors). On the pinned 1.96.0 it is still only a # warning — exactly as it already was for the three Apple jobs that -# were green throughout. +# were green throughout. (The image stopped injecting it on 2026-09-03; +# see `.core-defs`.) # # `!reference [.libretro-rust-apple-base, script]` pulls in the generic Apple # build/package steps instead of duplicating them here, so upstream fixes to @@ -326,9 +346,9 @@ libretro-build-ios-arm64: # tvOS template already sets to `aarch64-apple-tvos`, and honours the same # `STRIP_CORE_LIB` / `DSYM_CORE_LIB` flags the template sets. # -# NOTE: `-C ar` is still a latent hazard for ALL FOUR Apple jobs, now -# including this one. Read `rust-toolchain.toml` before bumping `channel` to -# 1.97 or newer. +# NOTE: `-C ar` WAS a latent hazard for all four Apple jobs until the build +# image stopped injecting it (2026-09-03). If a future image brings it back, +# the two-line `before_script` fix is recorded in `rust-toolchain.toml`. libretro-build-tvos-arm64: extends: - .libretro-rust-tvos-arm64-default @@ -337,7 +357,7 @@ libretro-build-tvos-arm64: RUST_TARGET: aarch64-apple-tvos before_script: - !reference [.libretro-rust-tvos-arm64-default, before_script] - - rustup target add ${RUST_TARGET} + - rustup toolchain install ${RUSTUP_TOOLCHAIN} --profile minimal --target ${RUST_TARGET} script: - !reference [.libretro-rust-apple-base, script] @@ -346,8 +366,8 @@ libretro-build-tvos-arm64: # Built in libretro's webOS image, which has its own rustup, libclang and the # LG webOS SDKs. The template's `before_script` sources the SDK environment, # so it is kept via `!reference`, and the #91899 `rustup target add` follows -# it: the `channel` pin makes rustup fetch a pristine 1.96.0 here too, without -# the targets the image installed on its own default toolchain. +# it: the pinned toolchain (`RUSTUP_TOOLCHAIN`) is a pristine install here too, +# without the targets the image installed on its own default toolchain. libretro-build-webos-armv7a: extends: - .libretro-rust-webos-armv7a-default @@ -356,7 +376,7 @@ libretro-build-webos-armv7a: RUST_TARGET: armv7-unknown-linux-gnueabi before_script: - !reference [.libretro-rust-webos, before_script] - - rustup target add ${RUST_TARGET} + - rustup toolchain install ${RUSTUP_TOOLCHAIN} --profile minimal --target ${RUST_TARGET} # webOS aarch64 # The image points bindgen at the SDK sysroot for armv7 only @@ -379,4 +399,4 @@ libretro-build-webos-aarch64: BINDGEN_EXTRA_CLANG_ARGS_aarch64_unknown_linux_gnu: "--sysroot=/developer/aarch64-webos-linux-gnu_sdk-buildroot/aarch64-webos-linux-gnu/sysroot" before_script: - !reference [.libretro-rust-webos, before_script] - - rustup target add ${RUST_TARGET} + - rustup toolchain install ${RUSTUP_TOOLCHAIN} --profile minimal --target ${RUST_TARGET} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5218580c7..25cef413c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -70,7 +70,7 @@ repos: # here, like every other hook, so a new ruff release cannot redden unchanged # scripts. - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.16.9 + rev: v0.16.10 hooks: - id: ruff-check diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 5d01ec87c..4a0187b71 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -65,8 +65,8 @@ android { // `scripts/release-automation/bump_release.py` from now on, starting // with the 3.0.0 cut. versionCode = MAJOR * 10000 + MINOR * 100 + // PATCH, so 20909 still rises past 20004. - versionCode = 30000 - versionName = "3.0.0" + versionCode = 30001 + versionName = "3.0.1" // No abiFilters here — set per buildType so release ships arm64 only // while debug keeps x86_64 for the emulator. // PLAY_BUILD is set per-flavor below (`false` for `foss`, `true` for `play`), @@ -425,7 +425,7 @@ dependencies { // a stub, and with `isReturnDefaultValues` it returns null / 0 instead of // throwing, so a test of the JSON stores (game_config.json, library.json) // would pass or fail on stub defaults, not on the code. - testImplementation("org.json:json:20250517") + testImplementation("org.json:json:20260814") // v2.0.1 (ADR 0025): the optional Google Play services below are PLAY-FLAVOR ONLY. // `playImplementation` keeps these proprietary Google-Play SDKs out of the `foss` // (F-Droid/sideload) artifact entirely — the `foss` variant links none of them (its diff --git a/crates/rustynes-test-harness/golden/tricnes/README.md b/crates/rustynes-test-harness/golden/tricnes/README.md index 63074df38..9e8fca788 100644 --- a/crates/rustynes-test-harness/golden/tricnes/README.md +++ b/crates/rustynes-test-harness/golden/tricnes/README.md @@ -1,24 +1,26 @@ -# TriCNES — vendored reference oracle (MIT) +# TriCNES cross-diff evidence (the source now lives outside the repository) -TriCNES is the AccuracyCoin author's own emulator (Chris "100th_Coin" Siebert), which passes the full -144-test battery — the gold oracle for these tests. Re-synced 2026-09-19 to upstream `94f1b117` -(previously `f388af0`, 2026-09-11, and `f54d8be`, 2026-05-05); that window carries the OAM2-address -and OAM-evaluation fixes matching AccuracyCoin's new `Advanced Sprite Evaluation` page, the 6502 -internal-data-bus fix, Mapper 66 (GxROM), and — in `94f1b117` — a one-line RESET fix -(`CPU_SYNC = true;` in `Reset()`). `tricnes-full-src/` is byte-identical to upstream `94f1b117`; -the same one line was applied by hand to the instrumented `tricnes-harness-src/Emulator.cs`, whose -delta against the full source stays at its established 88 instrumentation lines. Vendored here under its **MIT License** (see -`tricnes-full-src/LICENSE`) as the per-cycle cross-diff oracle for the DMA-tail / Program-M work, -salvaged from `/tmp` so it survives reboot. +TriCNES is the AccuracyCoin author's own emulator (Chris "100th_Coin" Siebert, **MIT**), which +passes the full AccuracyCoin battery and served as the per-cycle cross-diff oracle for the +DMA-tail / Program-M work. Several of its models are ported into RustyNES and attributed in +`NOTICE` and `docs/originality-and-provenance.md` section 1. -- **`tricnes-harness-src/`** — the trimmed, **instrumented** harness actually used for the cross-diff: - `Emulator.cs` (with the per-cycle window logger), `Program.cs`, `6502Documentation.cs`, `mappers/` - (all 11 `Mapper_*.cs`, required to build — the re-sync added `Mapper_GxROM.cs`), `tricnes-harness.csproj`. Build: `dotnet build -c Release` - (.NET 10 SDK). The MIT license in `../tricnes-full-src/LICENSE` covers this trimmed copy too. -- **`tricnes-full-src/`** — the complete upstream TriCNES source (`.cs`/`.csproj`/`.resx` + `LICENSE`, - no build artifacts), for reference / re-trimming the harness. -- **`implicit_abort_*_xdiff_*.txt` / `implicit_abort_region.txt`** — committed cross-diff outputs. +**Moved out in v3.0.1 (maintainer decision, 2026-10-07).** The vendored source trees +(`tricnes-full-src/`, byte-identical to upstream `94f1b117`, and `tricnes-harness-src/`, that +source plus 88 instrumentation lines) were removed from the repository so no search over it +reaches reference-emulator source. They now live at: -Upstream: `github.com/100thCoin/TriCNES`. Reverse-engineered model: -`docs/audit/v2.0-f2-tricnes-reference-model-2026-06-02.md`. Setup/regeneration: -`docs/tooling/oracle-tooling-setup.md` §2 / §2a. +- `~/reference-oracles/TriCNES`: a clone of `github.com/100thCoin/TriCNES` at `94f1b117` + (checked file-for-file against the vendored copy before removal; upstream additionally carries + its `.sln`, `icon.ico`, `SDL2.dll` and settings file, which were never vendored); +- `~/reference-oracles/TriCNES-rustynes-harness`: the instrumented harness, with `LICENSE`. + Build: `dotnet build -c Release` (.NET 10 SDK). + +On another machine, re-create them from upstream; the harness is in this repository's history +before the v3.0.1 removal commit. When and how the source may be consulted (AccuracyCoin work, +after rungs 1-3, always attributed) is `docs/ai-emulator-provenance-guardrails.md` section 3a. + +What stays here is evidence, not source: the committed cross-diff outputs +(`implicit_abort_*_xdiff_*.txt`, `implicit_abort_region.txt`, `implicit_540_grid_xdiff_*.txt`). +Reverse-engineered model: `docs/audit/v2.0-f2-tricnes-reference-model-2026-06-02.md`. Tooling: +`docs/tooling/oracle-tooling-setup.md` sections 2 and 2a. diff --git a/crates/rustynes-test-harness/src/bin/fds_smoke.rs b/crates/rustynes-test-harness/src/bin/fds_smoke.rs index 7c28d6eea..6b670dfa0 100644 --- a/crates/rustynes-test-harness/src/bin/fds_smoke.rs +++ b/crates/rustynes-test-harness/src/bin/fds_smoke.rs @@ -102,7 +102,9 @@ fn main() { match result { Ok(Ok(fb)) => { let colours: HashSet<[u8; 4]> = fb - .chunks_exact(4) + .as_chunks::<4>() + .0 + .iter() .map(|c| [c[0], c[1], c[2], c[3]]) .collect(); let n = colours.len(); diff --git a/crates/rustynes-test-harness/src/bin/fds_swap_repro.rs b/crates/rustynes-test-harness/src/bin/fds_swap_repro.rs index dc310df90..5ba944edc 100644 --- a/crates/rustynes-test-harness/src/bin/fds_swap_repro.rs +++ b/crates/rustynes-test-harness/src/bin/fds_swap_repro.rs @@ -52,7 +52,9 @@ fn fb_hash(fb: &[u8]) -> u64 { } fn colour_count(fb: &[u8]) -> usize { - fb.chunks_exact(4) + fb.as_chunks::<4>() + .0 + .iter() .map(|c| [c[0], c[1], c[2], c[3]]) .collect::>() .len() diff --git a/crates/rustynes-test-harness/src/bin/fds_trace.rs b/crates/rustynes-test-harness/src/bin/fds_trace.rs index a9cf4867f..de7a8de49 100644 --- a/crates/rustynes-test-harness/src/bin/fds_trace.rs +++ b/crates/rustynes-test-harness/src/bin/fds_trace.rs @@ -55,7 +55,7 @@ fn main() { // from the raw .fds (header-less form; each side is FDS_SIDE_LEN bytes). // A headered `.fds` starts with "FDS\x1a" + a 16-byte header; require the // full header before slicing it off so a short/corrupt file can't panic. - let body = if disk.len() >= 16 && &disk[..3] == b"FDS" { + let body = if disk.len() >= 16 && disk.starts_with(b"FDS\x1a") { &disk[16..] } else { &disk[..] @@ -275,7 +275,7 @@ mod tests { let reference = vec![0x5Au8; INFO_BLOCK_LEN]; let (v, diffs) = compare_info_block(&reference[..20], &reference); assert_eq!(v, BlockVerdict::Truncated { compared: 20 }); - assert!(diffs.is_empty()); + assert_eq!(diffs, [] as [(usize, u8, u8); 0]); } #[test] diff --git a/crates/rustynes-test-harness/src/bin/repro_smb3.rs b/crates/rustynes-test-harness/src/bin/repro_smb3.rs index 8b714d384..2a9e8366a 100644 --- a/crates/rustynes-test-harness/src/bin/repro_smb3.rs +++ b/crates/rustynes-test-harness/src/bin/repro_smb3.rs @@ -224,8 +224,8 @@ fn replay_movie(nes: &mut Nes, movie_path: &str, out_dir: &Path, dump_every: u64 // On-screen sprites only, in OAM order, so we can see which compete on // Mario's scanline and in what order (the drop is order-dependent). let mut onlist: Vec<(usize, u8, u8, u8)> = Vec::new(); - for (i, s) in oam.chunks_exact(4).enumerate() { - let (y, tile, _attr, x) = (s[0], s[1], s[2], s[3]); + for (i, s) in oam.as_chunks::<4>().0.iter().enumerate() { + let [y, tile, _attr, x] = *s; if y < 0xEF { onscreen += 1; if (40..96).contains(&y) { @@ -299,7 +299,7 @@ fn observe_idle(nes: &mut Nes, n: usize) { // Mario; detect "any on-screen sprite in his centre box". // Precise detector: small-Mario's body tiles (0x05/0x07) on-screen, // not a loose box (which catches coins/enemies/projectiles as noise). - let mario_present = oam.chunks_exact(4).any(|s| { + let mario_present = oam.as_chunks::<4>().0.iter().any(|s| { let (y, tile) = (s[0], s[1]); y < 0xEF && matches!(tile, 0x05 | 0x07) }); @@ -330,7 +330,7 @@ fn observe_idle(nes: &mut Nes, n: usize) { /// Mario's body tiles in the OAM/RAM sprite buffer (small Mario, idle). #[cfg(feature = "debug-hooks")] fn mario_in_buf(buf: &[u8]) -> bool { - buf.chunks_exact(4).any(|s| { + buf.as_chunks::<4>().0.iter().any(|s| { let (y, tile) = (s[0], s[1]); y < 0xEF && matches!(tile, 0x05 | 0x07) }) @@ -428,7 +428,9 @@ fn diag_idle(nes: &mut Nes, n: usize) { if ram_mario && !oam_mario { // Find where Mario's tile pair (0x05 at some +1 offset) sits in RAM. let ram_idx = ram_buf - .chunks_exact(4) + .as_chunks::<4>() + .0 + .iter() .position(|s| s[0] < 0xEF && matches!(s[1], 0x05 | 0x07)); // Detect a global byte-shift: best offset k minimising sum|oam[i]-ram[i-k]|. let mut best_k = 0i32; diff --git a/crates/rustynes-test-harness/src/bin/smb3_dma_trace.rs b/crates/rustynes-test-harness/src/bin/smb3_dma_trace.rs index 50787b671..c9604534e 100644 --- a/crates/rustynes-test-harness/src/bin/smb3_dma_trace.rs +++ b/crates/rustynes-test-harness/src/bin/smb3_dma_trace.rs @@ -26,7 +26,9 @@ use rustynes_core::irq_trace::BusAccess; use rustynes_core::{Buttons, Movie, MoviePlayer, Nes}; fn mario_in_buf(buf: &[u8]) -> bool { - buf.chunks_exact(4) + buf.as_chunks::<4>() + .0 + .iter() .any(|s| s[0] < 0xEF && matches!(s[1], 0x05 | 0x07)) } diff --git a/crates/rustynes-test-harness/src/bin/vs_dual_trace.rs b/crates/rustynes-test-harness/src/bin/vs_dual_trace.rs index 68577e07a..d0a89bb04 100644 --- a/crates/rustynes-test-harness/src/bin/vs_dual_trace.rs +++ b/crates/rustynes-test-harness/src/bin/vs_dual_trace.rs @@ -32,7 +32,9 @@ fn main() { let mut sub_pcs: HashMap = HashMap::new(); let colours = |fb: &[u8]| { - fb.chunks_exact(4) + fb.as_chunks::<4>() + .0 + .iter() .map(|c| [c[0], c[1], c[2], c[3]]) .collect::>() .len() diff --git a/crates/rustynes-test-harness/src/bin/zapper_light_probe.rs b/crates/rustynes-test-harness/src/bin/zapper_light_probe.rs index fb8155ea8..56ad98738 100644 --- a/crates/rustynes-test-harness/src/bin/zapper_light_probe.rs +++ b/crates/rustynes-test-harness/src/bin/zapper_light_probe.rs @@ -232,7 +232,6 @@ fn main() { let _ = nes.drain_audio_into(&mut discard); let (centre, bright_px) = aperture_stats(nes.framebuffer(), aim_x_runtime, aim_y_runtime); - let bright = bright_px >= 2; let reads: Vec = nes .accesses() .iter() @@ -243,9 +242,13 @@ fn main() { // test" from ordinary gameplay, which is how the Duck Hunt sequence is // recognisable at all. let fb = nes.framebuffer(); - let mean_luma: u32 = fb.chunks_exact(4).map(|p| u32::from(p[1])).sum::() + let mean_luma: u32 = fb + .as_chunks::<4>() + .0 + .iter() + .map(|p| u32::from(p[1])) + .sum::() / u32::try_from(fb.len() / 4).unwrap_or(1); - let _ = bright; if let Ok(dir) = std::env::var("ZAPPER_PROBE_PNG_DIR") { write_png( &Path::new(&dir).join(format!("f{f:03}.png")), diff --git a/crates/rustynes-test-harness/src/coverage.rs b/crates/rustynes-test-harness/src/coverage.rs index ddf5183ca..77263d95f 100644 --- a/crates/rustynes-test-harness/src/coverage.rs +++ b/crates/rustynes-test-harness/src/coverage.rs @@ -101,7 +101,9 @@ pub fn frame_health(fb: &[u8]) -> FrameHealth { // per-pixel hashing, a single allocation). The distinct-colour count and // dominant-colour fraction are identical to the map-based tally. let mut pixels: Vec = fb - .chunks_exact(4) + .as_chunks::<4>() + .0 + .iter() .map(|px| u32::from_le_bytes([px[0], px[1], px[2], px[3]])) .collect(); let total = pixels.len(); diff --git a/crates/rustynes-test-harness/tests/feature_flag_audit.rs b/crates/rustynes-test-harness/tests/feature_flag_audit.rs index 8ac78940c..d500a67fe 100644 --- a/crates/rustynes-test-harness/tests/feature_flag_audit.rs +++ b/crates/rustynes-test-harness/tests/feature_flag_audit.rs @@ -149,36 +149,129 @@ fn declared_features(root: &Path) -> BTreeMap> { /// table. Fails closed if the table cannot be located: a heading rename must /// break this test rather than silently make it check nothing. fn tabled_flags(root: &Path) -> Vec<(String, bool)> { - const HEADER: &str = "| Flag | Crate(s) | Default | Purpose |"; let path = root.join("docs/STATUS.md"); let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display())); + let rows = parse_feature_table(&text); + assert!( + rows.len() > 5, + "parsed only {} rows from the feature table -- the parser is broken", + rows.len() + ); + rows +} + +/// The feature table's rows in `text`, as `(flag, row_is_marked_removed)`. +/// +/// The table ends at the first line that does not start with `|`. Until v3.0.1 +/// it ended at the next BLANK line, so prose placed directly under the table +/// was read as table text (and with no blank line, everything to EOF was). +/// +/// Every `|` line after the header and separator must parse as a flag row, +/// or this panics naming it. Until v3.0.1 a malformed row was skipped with +/// `continue`, so it disappeared while the row-count floor still passed. +fn parse_feature_table(text: &str) -> Vec<(String, bool)> { + const HEADER: &str = "| Flag | Crate(s) | Default | Purpose |"; let start = text.find(HEADER).unwrap_or_else(|| { panic!( "{HEADER:?} not found in docs/STATUS.md -- if the feature table \ was renamed or moved, update this audit in the same change" ) }); - let table = &text[start..]; - let end = table.find("\n\n").unwrap_or(table.len()); let mut rows = Vec::new(); - for line in table[..end].lines().skip(2) { - let Some(rest) = line.strip_prefix("| `") else { - continue; - }; - let Some((flag, tail)) = rest.split_once('`') else { - continue; + for line in text[start..] + .lines() + .skip(2) + .take_while(|l| l.starts_with('|')) + { + let parsed = line + .strip_prefix("| `") + .and_then(|rest| rest.split_once('`')); + let Some((flag, tail)) = parsed else { + panic!( + "docs/STATUS.md's feature table has a row that does not parse as \ + \"| `flag` | ...\": {line:?}. A malformed row would otherwise \ + vanish from every check below." + ); }; // A retired flag is kept as a row for the historical record and marked. let removed = tail.contains("*(removed)*"); rows.push((flag.to_owned(), removed)); } + rows +} + +const SAMPLE_TABLE: &str = "\ +| Flag | Crate(s) | Default | Purpose | +|------|----------|---------|---------| +| `alpha` | `a` | off | first | +| `beta` *(removed)* | — | removed | second | +"; + +#[test] +fn the_table_ends_at_the_first_non_table_line() { + // Prose directly under the table, with no blank line, followed by a line + // that merely LOOKS like a row: it is not part of the table. + let text = format!("{SAMPLE_TABLE}Note under the table.\n| `gamma` | x | y | z |\n"); + assert_eq!( + parse_feature_table(&text), + vec![("alpha".to_owned(), false), ("beta".to_owned(), true)] + ); +} + +#[test] +#[should_panic(expected = "does not parse")] +fn a_malformed_row_is_refused_not_skipped() { + let text = format!("{SAMPLE_TABLE}| delta | x | y | z |\n"); + let _ = parse_feature_table(&text); +} + +/// Flags whose row is marked removed but which a manifest still declares. +fn removed_but_declared( + rows: &[(String, bool)], + declared: &BTreeMap>, +) -> Vec { + rows.iter() + .filter(|(flag, removed)| *removed && declared.contains_key(flag)) + .map(|(flag, _)| flag.clone()) + .collect() +} + +#[test] +fn removed_but_declared_finds_a_re_declared_flag() { + let rows = parse_feature_table(SAMPLE_TABLE); + let mut declared: BTreeMap> = BTreeMap::new(); + declared + .entry("alpha".into()) + .or_default() + .insert("a".into()); + assert_eq!(removed_but_declared(&rows, &declared), Vec::::new()); + declared + .entry("beta".into()) + .or_default() + .insert("b".into()); + assert_eq!( + removed_but_declared(&rows, &declared), + vec!["beta".to_owned()] + ); +} + +/// A row marked removed must name a flag no manifest declares. +/// +/// The other two directions both pass over removed rows: the first skips them, +/// the second counts them as documented. So a manifest that re-declared +/// `cpu-implied-dummy-reads` passed every test while this table called it +/// removed. Added in v3.0.1. +#[test] +fn no_removed_flag_is_still_declared() { + let root = repo_root(); + let stale = removed_but_declared(&tabled_flags(&root), &declared_features(&root)); assert!( - rows.len() > 5, - "parsed only {} rows from the feature table -- the parser is broken", - rows.len() + stale.is_empty(), + "docs/STATUS.md marks these flags *(removed)* but a crates/*/Cargo.toml \ + still declares them: {stale:?}. Either the flag is live again -- drop the \ + marker and document it -- or the declaration should go." ); - rows } /// A row may only claim a live flag if a manifest declares it. diff --git a/crates/rustynes-test-harness/tests/holy_mapperel.rs b/crates/rustynes-test-harness/tests/holy_mapperel.rs index 22258e629..52613f70a 100644 --- a/crates/rustynes-test-harness/tests/holy_mapperel.rs +++ b/crates/rustynes-test-harness/tests/holy_mapperel.rs @@ -166,7 +166,9 @@ fn fnv1a64(fb: &[u8]) -> u64 { /// has two (text + backdrop); a blanked crash screen collapses to one. fn distinct_colors(fb: &[u8]) -> usize { let mut px: Vec = fb - .chunks_exact(4) + .as_chunks::<4>() + .0 + .iter() .map(|p| u32::from_le_bytes([p[0], p[1], p[2], p[3]])) .collect(); px.sort_unstable(); diff --git a/crates/rustynes-test-harness/tests/provenance_record_audit.rs b/crates/rustynes-test-harness/tests/provenance_record_audit.rs index 58602db86..c21414a78 100644 --- a/crates/rustynes-test-harness/tests/provenance_record_audit.rs +++ b/crates/rustynes-test-harness/tests/provenance_record_audit.rs @@ -37,12 +37,11 @@ fn workspace_root() -> PathBuf { /// §1 rows that name a file with no header of its own, each with the reason. /// The list is where a reviewer sees the exception argued. -const ROW_WITHOUT_HEADER: &[(&str, &str)] = &[( - "crates/rustynes-gfx-shaders/src/lib.rs", - "named in the crt_stack.rs row because it re-exports CRT_ROYALE_WGSL / CRT_GUEST_WGSL / \ - MEGATRON_WGSL; the reimplemented shaders themselves live in crt_stack.rs, which carries \ - the header", -)]; +const ROW_WITHOUT_HEADER: &[(&str, &str)] = &[]; +// `crates/rustynes-gfx-shaders/src/lib.rs` was the one entry until v3.0.1: it was +// named in the crt_stack.rs row only because it re-exports the CRT shaders. It also +// carries `BISQWIT_WGSL`, a generated copy of derived code, so it now has a header and +// a row of its own (T-NTSC-PROVENANCE). fn rs_files(dir: &Path, out: &mut Vec) { // An unreadable directory fails the test rather than being skipped: a diff --git a/crates/rustynes-test-harness/tests/release_anchor_audit.rs b/crates/rustynes-test-harness/tests/release_anchor_audit.rs index 5a51af2ed..b5216996e 100644 --- a/crates/rustynes-test-harness/tests/release_anchor_audit.rs +++ b/crates/rustynes-test-harness/tests/release_anchor_audit.rs @@ -630,9 +630,8 @@ fn the_changelog_has_a_section_for_the_workspace_version() { // correct in the eight anchor documents that DO render markdown. Only this // one string crosses into a plain-text surface, which is exactly why // nothing caught it: every other consumer of the same words was fine. - for marker in ["**", "__", "`"] { - assert!( - !theme.contains(marker), + if let Some(marker) = title_markdown_marker(theme) { + panic!( "CHANGELOG header for {version} carries the markdown marker {marker:?} \ in its theme. That theme is parsed by release-auto.yml into the \ GitHub release title, which is NOT markdown-rendered, so the marker \ @@ -641,6 +640,60 @@ fn the_changelog_has_a_section_for_the_workspace_version() { } } +/// The first markdown marker in a release-title theme that would appear +/// literally in the plain-text GitHub release title, or `None`. +/// +/// A lone `*` or `_` stays allowed: both occur in ordinary prose (`3*4`, +/// `snake_case`). A PAIR that opens and closes emphasis (`*word*`, `_word_`) is +/// refused. Strikethrough (`~~`), a link (`](`) and the single-delimiter pair +/// were added in v3.0.1; until then only `**`, `__` and a backtick were. +fn title_markdown_marker(theme: &str) -> Option<&'static str> { + if let Some(marker) = ["**", "__", "`", "~~", "]("] + .into_iter() + .find(|marker| theme.contains(marker)) + { + return Some(marker); + } + [('*', "*"), ('_', "_")] + .into_iter() + .find(|&(d, _)| has_emphasis_pair(theme, d)) + .map(|(_, marker)| marker) +} + +/// True when `d` opens emphasis (not after a word character, not before space +/// or another `d`) and a later `d` closes it (not after space, not before a +/// word character), which is the shape a Markdown renderer treats as emphasis. +fn has_emphasis_pair(theme: &str, d: char) -> bool { + let chars: Vec = theme.chars().collect(); + let word = |c: char| c.is_alphanumeric() || c == '_'; + (0..chars.len()).any(|i| { + let opens = chars[i] == d + && i.checked_sub(1).is_none_or(|p| !word(chars[p])) + && chars + .get(i + 1) + .is_some_and(|&n| !n.is_whitespace() && n != d); + opens + && (i + 2..chars.len()).any(|j| { + chars[j] == d + && !chars[j - 1].is_whitespace() + && chars.get(j + 1).is_none_or(|&n| !word(n)) + }) + }) +} + +#[test] +fn title_markers_cover_strikethrough_and_links() { + assert_eq!(title_markdown_marker("(a **bold** claim)"), Some("**")); + assert_eq!(title_markdown_marker("(a ~~struck~~ claim)"), Some("~~")); + assert_eq!(title_markdown_marker("(see [the notes](url))"), Some("](")); + // A PAIRED single delimiter is emphasis too (v3.0.1, Copilot on #592). + assert_eq!(title_markdown_marker("(an *important* fix)"), Some("*")); + assert_eq!(title_markdown_marker("(an _important_ fix)"), Some("_")); + // Ordinary prose stays allowed. + assert_eq!(title_markdown_marker("(the API major_version, 3*4)"), None); + assert_eq!(title_markdown_marker("(snake_case and 2 * 3 * 4)"), None); +} + /// Anchors that quote a codename must quote the CHANGELOG's codename. /// /// A correct version beside the previous release's codename is still a wrong diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_045_GA23C_Famicom_Yarou_Vol_1_7_in_1_Unl.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_045_GA23C_Famicom_Yarou_Vol_1_7_in_1_Unl.snap index 935bbaaf7..21cdbf6c2 100644 --- a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_045_GA23C_Famicom_Yarou_Vol_1_7_in_1_Unl.snap +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_045_GA23C_Famicom_Yarou_Vol_1_7_in_1_Unl.snap @@ -1,6 +1,5 @@ --- source: crates/rustynes-test-harness/tests/external_coverage.rs -assertion_line: 681 expression: text --- rom=mapper-045-GA23C/Famicom Yarou Vol.1 7-in-1 (Unl) [!].nes @@ -10,5 +9,5 @@ fb_bytes=245760 cycles=33949775 audio_samples=836505 audio_fnv1a64=d046673ae808f5c4 -checkpoint f900 fb_fnv1a64=2474ee6337073864 -checkpoint f1100 fb_fnv1a64=2474ee6337073864 +checkpoint f900 fb_fnv1a64=5cab1ca79e100154 +checkpoint f1100 fb_fnv1a64=5cab1ca79e100154 diff --git a/crates/rustynes-test-harness/tests/vs_dualsystem.rs b/crates/rustynes-test-harness/tests/vs_dualsystem.rs index 8c186a729..16b1a75e6 100644 --- a/crates/rustynes-test-harness/tests/vs_dualsystem.rs +++ b/crates/rustynes-test-harness/tests/vs_dualsystem.rs @@ -123,7 +123,9 @@ fn boot_dual(rel: &str, frames: u64) -> VsDualSystem { /// Count distinct RGBA colours in a framebuffer (blank/crash heuristic). fn colour_count(fb: &[u8]) -> usize { - fb.chunks_exact(4) + fb.as_chunks::<4>() + .0 + .iter() .map(|c| [c[0], c[1], c[2], c[3]]) .collect::>() .len() diff --git a/crates/rustynes-test-harness/tests/vs_system_rgb.rs b/crates/rustynes-test-harness/tests/vs_system_rgb.rs index c228ee69a..3f5574b54 100644 --- a/crates/rustynes-test-harness/tests/vs_system_rgb.rs +++ b/crates/rustynes-test-harness/tests/vs_system_rgb.rs @@ -105,7 +105,9 @@ fn boot_with_coin(rel: &str, frames: u64) -> Vec { fn assert_rgb_routed(label: &str, fb: &[u8]) { assert_eq!(fb.len() % 4, 0, "{label}: framebuffer must be RGBA"); let colours: HashSet<[u8; 4]> = fb - .chunks_exact(4) + .as_chunks::<4>() + .0 + .iter() .map(|c| [c[0], c[1], c[2], c[3]]) .collect(); diff --git a/deploy/Dockerfile b/deploy/Dockerfile index 347a5475e..41af6f461 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -11,8 +11,12 @@ # reach it. Pair with a STUN/TURN server (coturn) for NAT traversal. # --- build stage --------------------------------------------------------------- -# Pin to the workspace MSRV (rust-toolchain.toml channel = 1.86.0). -FROM rust:1.86-bookworm AS build +# Match the workspace toolchain (rust-toolchain.toml channel = 1.99.0, v3.0.1). +# rustup inside the image reads that file from the copied workspace and +# installs the pinned channel regardless, so this tag mainly saves the +# download; keep the two equal. (It said 1.86 for two years after the pin +# moved to 1.96; the build was still correct only because of that override.) +FROM rust:1.99-trixie AS build WORKDIR /src # Copy the whole workspace (the example depends on the rustynes-netplay crate + @@ -27,7 +31,9 @@ RUN cargo build --release --locked \ -p rustynes-netplay --features signaling-server --example signaling_server # --- runtime stage ------------------------------------------------------------- -FROM debian:bookworm-slim AS runtime +# Same Debian release as the build stage, so the binary links against the +# glibc it was built with. +FROM debian:trixie-slim AS runtime # Non-root user for the long-running service. RUN useradd --system --create-home --shell /usr/sbin/nologin signal COPY --from=build /src/target/release/examples/signaling_server /usr/local/bin/signaling_server diff --git a/deploy/Dockerfile.raproxy b/deploy/Dockerfile.raproxy index afc8b08aa..16db9f8ea 100644 --- a/deploy/Dockerfile.raproxy +++ b/deploy/Dockerfile.raproxy @@ -16,7 +16,7 @@ # Build context is the workspace root (so the source script under scripts/ is # reachable), matching the signaling image: # docker build -f deploy/Dockerfile.raproxy -t rustynes-raproxy .. -FROM python:3.12-slim +FROM python:3.14-slim # Non-root for defence in depth — the proxy needs no privilege. RUN useradd --create-home --uid 10001 raproxy diff --git a/ios/README.md b/ios/README.md index ba80a256a..724b0b885 100644 --- a/ios/README.md +++ b/ios/README.md @@ -73,7 +73,7 @@ the gamepad mapper both build this mask and feed it to Prerequisites (macOS with Xcode): - Xcode 15 or newer. -- The Rust toolchain pinned by `rust-toolchain.toml` (1.96). +- The Rust toolchain pinned by `rust-toolchain.toml` (1.99). - The iOS Rust targets (the build script adds them): `aarch64-apple-ios`, `aarch64-apple-ios-sim`, `x86_64-apple-ios`. - XcodeGen: `brew install xcodegen`. diff --git a/ios/RustyNES/GameView.swift b/ios/RustyNES/GameView.swift index 63c7c554e..ac8caed72 100644 --- a/ios/RustyNES/GameView.swift +++ b/ios/RustyNES/GameView.swift @@ -153,11 +153,12 @@ struct GameView: View { .sheet(isPresented: $showingDebugger) { DebuggerView() } - // Pause the emulator while a menu/sheet is open so the player doesn't lose - // progress or hear audio behind it; resume once all are dismissed. The TAS / - // Movies + Cheats panels are exceptions: recording / playback / live cheats - // must keep the core running, so they do NOT pause emulation. The read-only - // debugger DOES pause (its "Step" button advances exactly one frame). + // Pause the emulator while one of three sheets is open -- Save States, + // Settings, or the read-only Debugger (its "Step" button advances exactly + // one frame) -- and resume once all three are dismissed. Every other sheet + // (Movies, TAStudio, Cheats, Netplay, Achievements, Lua) keeps the core + // running (recording / playback / live cheats need it, which is why those + // panels were made exceptions in the first place). .onChange(of: showingStates) { _ in updateMenuPaused() } .onChange(of: showingSettings) { _ in updateMenuPaused() } .onChange(of: showingDebugger) { _ in updateMenuPaused() } @@ -172,8 +173,8 @@ struct GameView: View { } /// Recompute whether a modal that must pause emulation is open (Save States, - /// Settings, or the read-only Debugger). Cheats / TAStudio / Movies keep the - /// core running by design. + /// Settings, or the read-only Debugger). These three are the only sheets that + /// pause; every other sheet keeps the core running by design. private func updateMenuPaused() { model.setMenuPaused(showingStates || showingSettings || showingDebugger) } diff --git a/ios/project.yml b/ios/project.yml index 4e6f36a38..cd5f3c977 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -45,7 +45,7 @@ settings: # 2.0.8 through v2.9.9, since nothing moved it), and moved by # `scripts/release-automation/bump_release.py` from now on, starting with # the 3.0.0 cut. - MARKETING_VERSION: "3.0.0" + MARKETING_VERSION: "3.0.1" # The build (not marketing) number. Each TestFlight upload needs a UNIQUE one; # the fastlane `beta` lane overrides this with the CI run number at build time # (xcargs CURRENT_PROJECT_VERSION), so this checked-in "1" is only the local / diff --git a/mkdocs.yml b/mkdocs.yml index dc0b6a9fe..25fd564a3 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -183,6 +183,7 @@ nav: exclude_docs: | adr/ agents/ + history/ audits/ archive/ audit/ diff --git a/scripts/agy-review-selftest.sh b/scripts/agy-review-selftest.sh index 24b74cffe..b485f90d7 100755 --- a/scripts/agy-review-selftest.sh +++ b/scripts/agy-review-selftest.sh @@ -61,7 +61,7 @@ log() { :; } # otherwise extract EMPTY, and an empty guard sources fine and asserts nothing -- the same # absence-reads-as-agreement failure the markers were adopted to prevent. for guard in "service-error guard" "oauth guard" "ours-comment filter" "duration parser" \ - "numeric env validation" "diff-size scaling"; do + "numeric env validation" "diff-size scaling" "diff-limit classifier"; do blk="$(extract_block "$guard")" [ -n "$blk" ] || { echo "FAIL: SELFTEST-EXTRACT block '$guard' is missing or empty" >&2; exit 1; } printf '%s\n' "$blk" | bash -n - 2>/dev/null \ @@ -397,6 +397,18 @@ check "numeric env: canonical value is arithmetic-safe" "9" \ check "numeric env: the RAW value would have crashed" "CRASHED" \ "$(bash -c 'set -e; echo $(( 1048576 * 09 / 1048576 ))' 2>/dev/null || echo CRASHED)" +# --- the 406 diff-limit classifier ------------------------------------------------------- +# GitHub's two "too big for the API" refusals must BOTH reach the local-diff fallback, and the +# log must name the limit that fired. Matching only the lines variant failed a wide-but-shallow +# PR outright; the label is what a reader triages from. Anything else is a real failure. +dl() { printf '%s\n' "$1" > "$TMPD/derr"; diff_limit_hit "$TMPD/derr" || printf 'NO-FALLBACK'; } +check "406, lines variant: falls back, named 20,000-line" "20,000-line" \ + "$(dl 'could not find pull request diff: HTTP 406: Sorry, the diff exceeded the maximum number of lines (20000)')" +check "406, files variant: falls back, named 300-file" "300-file" \ + "$(dl 'could not find pull request diff: HTTP 406: Sorry, the diff exceeded the maximum number of files (300)')" +check "an unrelated gh error does not fall back" "NO-FALLBACK" \ + "$(dl 'could not find pull request diff: HTTP 404: Not Found')" + # --- the print-timeout guard ------------------------------------------------------------- # agy exits 0 on its own --print-timeout and prints a notice, alone or after a partial review. # `have_text` passed that, so a truncated review was posted as the whole one (2026-09-28). diff --git a/scripts/agy-review.sh b/scripts/agy-review.sh index c34530e24..02053f4f5 100755 --- a/scripts/agy-review.sh +++ b/scripts/agy-review.sh @@ -486,28 +486,36 @@ esac # ambient credential helper. # ($diff_err was allocated alongside $diff_file / $meta_file above, so the cleanup # trap never references it before it exists.) +# >>> SELFTEST-EXTRACT: diff-limit classifier +# GitHub refuses an oversized diff TWO ways, with different wording: over +# 20,000 lines, and over 300 FILES. Both are HTTP 406 and both mean the same +# thing here -- the PR is too big for the API, not that anything went wrong -- +# so both must reach the local fallback. Matching only the `lines` variant made +# a wide-but-shallow PR -- hundreds of files, well under the line limit, as a +# bulk regeneration of test baselines produces -- fail the review outright +# instead of falling back. +# +# On a match, print the limit that actually fired and succeed. Reporting +# "20,000-line" for a file-count refusal is the same class of misleading triage +# signal that made this bug look like a runner auth failure in the first place. +# A function, not inline, so the self-test executes the real matcher and label +# against both GitHub messages rather than grepping for its text. +diff_limit_hit() { + grep -qiE 'diff exceeded the maximum number of (lines|files)' "$1" || return 1 + if grep -qi 'maximum number of files' "$1"; then + printf '300-file' + else + printf '20,000-line' + fi +} +# <<< SELFTEST-EXTRACT if ! gh pr diff "$PR" --repo "$REPO" > "$diff_file" 2>"$diff_err"; then - # GitHub refuses an oversized diff TWO ways, with different wording: over - # 20,000 lines, and over 300 FILES. Both are HTTP 406 and both mean the same - # thing here -- the PR is too big for the API, not that anything went wrong -- - # so both must reach the local fallback. Matching only the `lines` variant made - # a wide-but-shallow PR -- hundreds of files, well under the line limit, as a - # bulk regeneration of test baselines produces -- fail the review outright - # instead of falling back. - if grep -qiE 'diff exceeded the maximum number of (lines|files)' "$diff_err"; then + if hit="$(diff_limit_hit "$diff_err")"; then base_ref="$(jq -r '.baseRefName // empty' "$meta_file")" if [ -z "$base_ref" ] || [ "$base_ref" = "null" ]; then log "diff exceeds the API limit and the base branch is unknown; cannot fall back" exit 1 fi - # Name the limit that actually fired. Reporting "20,000-line" for a - # file-count refusal is the same class of misleading triage signal that - # made this bug look like a runner auth failure in the first place. - if grep -qi 'maximum number of files' "$diff_err"; then - hit="300-file" - else - hit="20,000-line" - fi log "diff exceeds GitHub's ${hit} API limit; falling back to a local git diff" pr_ref="refs/agy/pr-${PR}" base_local="refs/agy/base-${PR}" @@ -565,8 +573,20 @@ if ! gh pr diff "$PR" --repo "$REPO" > "$diff_file" 2>"$diff_err"; then if [ -n "$api_base" ] && [ "$api_base" != "null" ]; then if git fetch --no-tags --quiet origin "$api_base" 2>/dev/null \ || git fetch --no-tags --quiet --deepen=250 origin "${fetch_refspecs[@]}" 2>/dev/null; then - merge_base="$(git merge-base "$base_local" "$pr_ref" 2>/dev/null || echo "$api_base")" - log "shallow clone: merge base ${merge_base} resolved via the compare API" + merge_base="$(git merge-base "$base_local" "$pr_ref" 2>/dev/null || true)" + # Fall back to the API's merge base only if that commit is actually + # here. When the SHA fetch fails and `--deepen=250` succeeds without + # reaching it, the object is absent, and using it anyway surfaced later + # as the generic "local git diff failed" -- the wrong diagnosis. + if [ -z "$merge_base" ]; then + if git cat-file -e "${api_base}^{commit}" 2>/dev/null; then + merge_base="$api_base" + else + log "compare API merge base ${api_base} is not in the local clone after both fetches" + fi + fi + [ -z "$merge_base" ] \ + || log "shallow clone: merge base ${merge_base} resolved via the compare API" fi fi fi diff --git a/scripts/diag/ppu2002_read_value_histogram.py b/scripts/diag/ppu2002_read_value_histogram.py index c12397a69..26caddcea 100644 --- a/scripts/diag/ppu2002_read_value_histogram.py +++ b/scripts/diag/ppu2002_read_value_histogram.py @@ -47,5 +47,8 @@ if masks[0]==0xE0 and masks[3]==0x00 and masks[0]>=masks[1]>=masks[2]>=masks[3]: sls=[r[2] for r in w]; dots=[r[3] for r in w] out.append(" win@%d sls=%s dots=%s masks=%s"%(i,sls,dots,[hex(m) for m in masks])) -open('/tmp/RustyNES/an_out.txt','w').write("\n".join(out)+"\n") -print("done",len(out)) +# The report goes to stdout (redirect it where you want it). Until v3.0.1 it +# was written to the fixed `/tmp/RustyNES/an_out.txt` -- the same shared-/tmp +# hazard the input path above had: a pre-planted symlink there redirects the +# write. stdout needs no directory, no open mode and no cleanup. +sys.stdout.write("\n".join(out)+"\n") diff --git a/scripts/perf/perf_log_check.py b/scripts/perf/perf_log_check.py index 4ef0b650e..97fbc561d 100755 --- a/scripts/perf/perf_log_check.py +++ b/scripts/perf/perf_log_check.py @@ -395,8 +395,23 @@ def main() -> int: # reporting that as "VALID — window was on screen" would assert something # never measured. The gate still passes it (see above), but it must not claim # to have checked. + # + # And a fourth (v3.0.1): ZERO is not proof of health either. The frontend + # writes `present_discarded` through a `map_or(0, ...)` on the presentation + # clock, so a run with no clock at all (non-Wayland, or the global never + # bound) logs 0 on every row -- and this branch used to call that "on screen + # throughout", the very claim docs/performance.md says zero cannot carry. + # `measured_refresh_hz` is set ONLY from that clock's answer, so a header + # value other than `none` is the evidence the counter was live. The header + # is written when logging starts, so a clock that answered later still + # reads `none`: that run is reported UNVERIFIED, which understates rather + # than overclaims. Reporting only -- the rate gate above is unchanged. + clock_seen = meta.get("measured_refresh_hz", "none").strip().lower() not in ("", "none") if not has_col: validity = "capture predates the column — validity UNKNOWN, not verified" + elif discarded == 0 and not clock_seen: + validity = ("validity UNVERIFIED (no presentation clock) — " + "measured_refresh_hz = none, so a zero count was never measured") elif discarded == 0: validity = "capture VALID — window was on screen throughout" elif disc_rate <= 1.0: diff --git a/scripts/pr-review/README.md b/scripts/pr-review/README.md index fdd4aad00..b22a68abd 100644 --- a/scripts/pr-review/README.md +++ b/scripts/pr-review/README.md @@ -12,10 +12,13 @@ gh api graphql -f query=' query($owner:String!,$repo:String!,$pr:Int!){ repository(owner:$owner,name:$repo){ pullRequest(number:$pr){ - reviewThreads(first:100){ nodes{ - id isResolved isOutdated path line - comments(first:1){ nodes{ databaseId body author{login} } } - }} + reviewThreads(first:100){ + pageInfo{ hasNextPage hasPreviousPage endCursor } + nodes{ + id isResolved isOutdated path line + comments(first:1){ nodes{ databaseId body author{login} } } + } + } } } }' -F owner=doublegate -F repo=RustyNES -F pr=325 \ @@ -28,6 +31,7 @@ gh api graphql -f query=' | `list_all_threads.py` | Every thread with its `isResolved` / `isOutdated` flags — the audit view, for confirming nothing was missed. | | `reply_and_resolve.py` | Applies prepared replies and resolves **only** the threads that were addressed. Dry run by default. | | `reply_and_resolve_selftest.py` | No-network selftest of the two above it. Run it after editing either. | +| `list_unresolved_threads_selftest.py` | No-network selftest of `list_unresolved_threads.py`: a payload with no thread list, or a partial thread node, must exit non-zero with a named error rather than print `0 unresolved thread(s)`, and so must a list that does not carry `pageInfo.hasNextPage` and `hasPreviousPage`, or says another page exists before or after it (added v3.0.1). Run it after editing that script. | ## Writing the outcome back diff --git a/scripts/pr-review/list_unresolved_threads.py b/scripts/pr-review/list_unresolved_threads.py index ff4742e27..db981f64c 100644 --- a/scripts/pr-review/list_unresolved_threads.py +++ b/scripts/pr-review/list_unresolved_threads.py @@ -52,15 +52,65 @@ def main() -> None: "GraphQL response has no repository/pullRequest data " "(check the owner/repo/pr arguments and token scope)" ) - threads = (pr.get("reviewThreads") or {}).get("nodes") or [] + # FAIL CLOSED. This is a closeout gate: "0 unresolved thread(s)" is the line + # that lets a merge go ahead, so it must mean "the payload listed threads and + # none was open" -- never "the payload had no thread list". Until v3.0.1 a + # missing or null `reviewThreads.nodes` (a query without the field, a partial + # response) collapsed to `[]` and printed exactly that all-clear. + threads = (pr.get("reviewThreads") or {}).get("nodes") + if not isinstance(threads, list): + raise SystemExit( + "GraphQL response has no reviewThreads.nodes list " + "(check that the query selects reviewThreads { nodes { ... } })" + ) + # A TRUNCATED list must fail closed too (v3.0.1, Copilot on #590): the query + # asks for `first:100`, and a PR with more threads used to print an + # all-clear for page one while later pages held open threads. The payload + # must say whether more pages exist, and this gate refuses if they do. + page = (pr.get("reviewThreads") or {}).get("pageInfo") + if not isinstance(page, dict) or not isinstance(page.get("hasNextPage"), bool): + raise SystemExit( + "GraphQL response has no reviewThreads.pageInfo.hasNextPage " + "(select it, so a truncated thread list cannot read as complete)" + ) + if page["hasNextPage"]: + raise SystemExit( + "more review threads than one page: refusing a partial count " + "(raise first:, or page with after: endCursor and check each page)" + ) + # The other end too (CodeRabbit on #592): a page fetched with `after:` can + # be the LAST page, with `hasNextPage: false`, while earlier pages hold + # open threads. Only a payload that starts at the first page is complete. + if not isinstance(page.get("hasPreviousPage"), bool): + raise SystemExit( + "GraphQL response has no reviewThreads.pageInfo.hasPreviousPage " + "(select it, so a later page cannot read as the whole list)" + ) + if page["hasPreviousPage"]: + raise SystemExit("this thread list is not the first page: refusing a partial count") shown = 0 - for thread in threads: + for i, thread in enumerate(threads): + # A partial node used to die on a bare KeyError/TypeError traceback; + # name the node and the field instead. + if not isinstance(thread, dict) or not isinstance(thread.get("isResolved"), bool): + raise SystemExit(f"review thread #{i} has no boolean isResolved") if thread["isResolved"]: continue - comments = thread["comments"]["nodes"] + if not thread.get("id"): + raise SystemExit(f"review thread #{i} has no id") + comments = (thread.get("comments") or {}).get("nodes") + if not isinstance(comments, list): + raise SystemExit(f"review thread #{i} ({safe(thread.get('id'))}) has no comments.nodes list") + # A real review thread always has a comment, so an empty list means a + # partial query (`comments(first:0)`) -- skipping it would let an open + # thread reach the all-clear (v3.0.1, Copilot on #592). if not comments: - continue + raise SystemExit(f"review thread #{i} ({safe(thread.get('id'))}) has no comments") c = comments[0] + if not isinstance(c, dict) or c.get("databaseId") is None: + raise SystemExit( + f"review thread #{i} ({safe(thread.get('id'))}): first comment has no databaseId" + ) author = (c.get("author") or {}).get("login") print( f"TID={safe(thread['id'])} dbId={safe(c['databaseId'])} " diff --git a/scripts/pr-review/list_unresolved_threads_selftest.py b/scripts/pr-review/list_unresolved_threads_selftest.py new file mode 100644 index 000000000..94080c348 --- /dev/null +++ b/scripts/pr-review/list_unresolved_threads_selftest.py @@ -0,0 +1,162 @@ +"""Selftest for `list_unresolved_threads.py` -- runs the real script, no network. + +Same precedent as `reply_and_resolve_selftest.py`: the test drives the ACTUAL +script (here as a subprocess, feeding a payload on stdin, exactly as the +ceremony pipes `gh api graphql` into it) rather than a copy of its rules. + +The cases that matter are the fail-closed ones. "0 unresolved thread(s)" is the +line that lets a merge go ahead, so a payload WITHOUT a thread list must be an +error, not an all-clear -- until v3.0.1 a missing or null `reviewThreads.nodes` +printed exactly that line and exited 0. + +Run: `python3 scripts/pr-review/list_unresolved_threads_selftest.py` +""" + +import json +import pathlib +import subprocess +import sys + +_SCRIPT = pathlib.Path(__file__).resolve().parent / "list_unresolved_threads.py" + +FAILURES = [] + + +def check(name: str, ok: bool, detail: str = "") -> None: + print(f" {'ok ' if ok else 'FAIL'} {name}{(' -- ' + detail) if detail and not ok else ''}") + if not ok: + FAILURES.append(name) + + +def run(doc) -> subprocess.CompletedProcess: + return subprocess.run( + [sys.executable, "-I", str(_SCRIPT)], + input=json.dumps(doc), + capture_output=True, + text=True, + encoding="utf-8", + check=False, + ) + + +def pr(review_threads) -> dict: + """A payload; a thread list gets a complete single page unless it says otherwise.""" + if isinstance(review_threads.get("reviewThreads"), dict): + review_threads["reviewThreads"].setdefault( + "pageInfo", {"hasNextPage": False, "hasPreviousPage": False} + ) + return {"data": {"repository": {"pullRequest": review_threads}}} + + +def refused(doc, because: str) -> bool: + """True only if the script exited non-zero FOR THE STATED REASON, with no traceback.""" + r = run(doc) + return r.returncode != 0 and because in r.stderr and "Traceback" not in r.stderr + + +def comment(db_id=7, body="b") -> dict: + return {"databaseId": db_id, "body": body, "author": {"login": "bot"}} + + +def thread(tid="T1", resolved=False, comments=None) -> dict: + return { + "id": tid, + "isResolved": resolved, + "path": "a.rs", + "line": 1, + "comments": {"nodes": [comment()] if comments is None else comments}, + } + + +def main() -> None: + print("list_unresolved_threads selftest") + + r = run(pr({"reviewThreads": {"nodes": [thread("T1"), thread("T2", resolved=True)]}})) + check( + "an open and a resolved thread list exactly one", + r.returncode == 0 and "TID=T1 dbId=7" in r.stdout and "T2" not in r.stdout + and r.stdout.rstrip().endswith("1 unresolved thread(s)"), + r.stdout + r.stderr, + ) + r = run(pr({"reviewThreads": {"nodes": []}})) + check( + "an EMPTY list is a genuine all-clear", + r.returncode == 0 and r.stdout.strip() == "0 unresolved thread(s)", + r.stdout + r.stderr, + ) + + # Fail closed: no list is not an empty list. + check( + "missing reviewThreads is refused, not 0 threads", + refused(pr({}), "no reviewThreads.nodes list"), + ) + check( + "null reviewThreads.nodes is refused, not 0 threads", + refused(pr({"reviewThreads": {"nodes": None}}), "no reviewThreads.nodes list"), + ) + + # Truncation: a list that does not say it is complete is refused. + check( + "a list without pageInfo is refused, not counted", + refused(pr({"reviewThreads": {"nodes": [], "pageInfo": None}}), "no reviewThreads.pageInfo.hasNextPage"), + ) + check( + "a list with more pages is refused, not counted", + refused( + pr({"reviewThreads": {"nodes": [thread("T1", resolved=True)], "pageInfo": {"hasNextPage": True}}}), + "more review threads than one page", + ), + ) + + check( + "a FINAL page with earlier pages is refused, not counted", + refused( + pr({"reviewThreads": {"nodes": [], "pageInfo": {"hasNextPage": False, "hasPreviousPage": True}}}), + "not the first page", + ), + ) + check( + "a page without hasPreviousPage is refused", + refused( + pr({"reviewThreads": {"nodes": [], "pageInfo": {"hasNextPage": False}}}), + "no reviewThreads.pageInfo.hasPreviousPage", + ), + ) + + # Partial nodes: a named error, never a bare KeyError traceback. + noid = thread("T1") + del noid["id"] + check( + "an open node without an id is refused by name", + refused(pr({"reviewThreads": {"nodes": [noid]}}), "has no id"), + ) + check( + "an open node with an EMPTY comment list is refused, not skipped", + refused(pr({"reviewThreads": {"nodes": [thread("T1", comments=[])]}}), "has no comments"), + ) + check( + "a node without isResolved is refused by name", + refused(pr({"reviewThreads": {"nodes": [{"id": "T1"}]}}), "has no boolean isResolved"), + ) + bad = thread("T1") + del bad["comments"] + check( + "an open node without comments.nodes is refused by name", + refused(pr({"reviewThreads": {"nodes": [bad]}}), "has no comments.nodes list"), + ) + check( + "a first comment without databaseId is refused by name", + refused( + pr({"reviewThreads": {"nodes": [thread("T1", comments=[{"body": "x"}])]}}), + "first comment has no databaseId", + ), + ) + + if FAILURES: + print(f"FAILED: {len(FAILURES)} check(s): {', '.join(FAILURES)}") + sys.exit(1) + print("all checks passed") + + +if __name__ == "__main__": + main() diff --git a/scripts/release-automation/bump_release.py b/scripts/release-automation/bump_release.py index f301f84b4..d751b181e 100755 --- a/scripts/release-automation/bump_release.py +++ b/scripts/release-automation/bump_release.py @@ -62,9 +62,15 @@ # version" and nothing enforced it. They were realigned by hand to the # workspace version (2.9.9) during v3.0.0's development, so the 3.0.0 cut is # the first bump that moves them. +# +# The two `Cargo.toml` patterns are anchored to a WHOLE LINE (`\nversion = ...\n`). +# Unanchored, `version = "3.0.0"` also matched every internal path dependency's +# `version = "3.0.0"` requirement, which v3.0.0 moved to the new major: the v3.0.1 +# cut found 14 matches and refused. The collision exists only while the outgoing +# release is an X.0.0, so no cut before v3.0.1 could have seen it. MANIFESTS = [ - ("Cargo.toml", 'version = "{v}"'), - ("crates/rustynes-cosim/Cargo.toml", 'version = "{v}"'), + ("Cargo.toml", '\nversion = "{v}"\n'), + ("crates/rustynes-cosim/Cargo.toml", '\nversion = "{v}"\n'), ("crates/rustynes-libretro/rustynes_libretro.info", 'display_version = "v{v}"'), ("android/app/build.gradle.kts", 'versionName = "{v}"'), ("android/app/build.gradle.kts", 'versionCode = {code}'), @@ -122,23 +128,67 @@ class Release: date: str +# Every file this script reads or writes is UTF-8 (the anchors are em-dash +# heavy), so the encoding is stated rather than inherited from the locale. +# Until v3.0.1 it was inherited: on a host whose locale is not UTF-8 the +# documents would fail to decode, or be rewritten in another encoding. +UTF8 = "utf-8" + + def run(cmd: list[str], cwd: Path) -> str: - return subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, check=True).stdout + return subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, encoding=UTF8, + check=True).stdout + + +def refresh_cosim_lock(root: Path) -> str: + """Refresh `crates/rustynes-cosim/Cargo.lock`; return "" or the failure. + + The excluded cosim crate keeps its own lockfile, and `cargo metadata` is + what rewrites it to the moved version. Until v3.0.1 a failure here was + caught as `Exception`, printed as a WARNING and ignored, so `--apply` + could exit 0 having left that lockfile on the old version. The caller now + fails on it. Only the two failures this call can have are caught -- cargo + exiting non-zero, and cargo not being installed -- so a bug in this script + still surfaces as a traceback rather than as a lockfile message. + """ + try: + run(["cargo", "metadata", "--manifest-path", + "crates/rustynes-cosim/Cargo.toml", "--format-version", "1"], root) + except subprocess.CalledProcessError as e: + detail = (e.stderr or "").strip().splitlines() + return f"`cargo metadata` exited {e.returncode}: {detail[-1] if detail else 'no stderr'}" + except FileNotFoundError as e: + return f"cannot run cargo: {e}" + return "" + + +def unescape_marker(m: str) -> str: + """Decode a Rust string literal's escapes (`\\"`, `\\\\`) in an ANCHORS marker. + + The obvious `m.encode().decode("unicode_escape")` -- what this did until + v3.0.1 -- re-reads the UTF-8 BYTES as Latin-1, so a non-ASCII marker comes + back garbled (`—` becomes `â\\x80\\x94`) and then matches no line, which + skips the anchor silently. No marker is non-ASCII today; one written as + `"Current release — v"` would have been. Encoding as Latin-1 with + `backslashreplace` turns every non-Latin-1 character into an escape that + `unicode_escape` decodes back to itself. + """ + return m.encode("latin-1", "backslashreplace").decode("unicode_escape") def anchors(root: Path) -> list[tuple[str, str]]: - src = (root / AUDIT).read_text() + src = (root / AUDIT).read_text(encoding=UTF8) i = src.index("const ANCHORS") blk = src[i: src.index("\n];", i)] out = re.findall(r'path:\s*"((?:[^"\\]|\\.)*)".*?marker:\s*"((?:[^"\\]|\\.)*)"', blk, re.S) if not out: raise SystemExit(f"no anchors parsed from {AUDIT}; the table moved") - return [(p, m.encode().decode("unicode_escape")) for p, m in out] + return [(p, unescape_marker(m)) for p, m in out] def changelog_releases(root: Path) -> list[Release]: rel = [] - for line in (root / CHANGELOG).read_text().splitlines(): + for line in (root / CHANGELOG).read_text(encoding=UTF8).splitlines(): m = SECTION.match(line) if m: rel.append(Release(m["v"], m["c"], m["d"])) @@ -148,7 +198,7 @@ def changelog_releases(root: Path) -> list[Release]: def workspace_version(root: Path) -> str: - t = (root / "Cargo.toml").read_text() + t = (root / "Cargo.toml").read_text(encoding=UTF8) seg = t[t.index("[workspace.package]"):] m = re.search(r'^version\s*=\s*"([^"]+)"', seg, re.M) if not m: @@ -224,11 +274,16 @@ def terminate(lead: str) -> str: A lead already ending in `.`, `!`, `?`, or a closing quote/bracket after one is left exactly as written -- including the `,` and `;` cases, where the author is deliberately continuing rather than ending. + + Until v3.0.1 the quote/bracket half of that promise was not kept: only the + last character was tested, so `... "quote."` and `(... .)` gained a + second stop. The closers are now looked through before the test. """ s = lead.rstrip() if not s: return s - return s if s[-1] in '.!?,;:' else s + "." + core = s.rstrip("\"')]’”") + return s if core and core[-1] in '.!?,;:' else s + "." def extend_chain(line: str, marker: str, old: Release, new: Release, @@ -321,7 +376,7 @@ def chains_needing_a_summary(texts: dict, root: Path, new: Release) -> list[str] p = root / rel if not p.is_file(): continue - text = texts.get(p, p.read_text()) + text = texts.get(p, p.read_text(encoding=UTF8)) at = 0 while True: idx = text.find(CHAIN_TAIL_PHRASE, at) @@ -426,10 +481,30 @@ def selftest() -> int: ("trailing space ", "trailing space."), ("a clause,", "a clause,"), ("", ""), + # v3.0.1: the docstring's closing quote/bracket promise, which the + # last-character test did not keep (two stops were appended). + ('the core says "enough."', 'the core says "enough."'), + ("the core rests (for now.)", "the core rests (for now.)"), + ("it reads “done.”", "it reads “done.”"), + # ...and a closer WITHOUT a stop inside still gets one, outside. + ("the core rests (for now)", "the core rests (for now)."), ]: got = terminate(raw) assert got == want, f"terminate({raw!r}) = {got!r}, want {want!r}" - print(" terminate(): 7 cases ok") + print(" terminate(): 11 cases ok") + + # v3.0.1: ANCHORS markers are Rust string literals. Escapes decode; a + # non-ASCII character survives (`encode().decode("unicode_escape")` + # turned `—` into `â\x80\x94`, so the anchor matched nothing). + for raw, want in [ + ("**Current release: v", "**Current release: v"), + ('say \\"v', 'say "v'), + ("Current release — v", "Current release — v"), + ("café — \\\\v", "café — \\v"), + ]: + got = unescape_marker(raw) + assert got == want, f"unescape_marker({raw!r}) = {got!r}, want {want!r}" + print(" unescape_marker(): 4 cases ok") old = Release("2.4.4", "Ignition", "2026-08-22") new = Release("2.4.5", "Compass", "2026-08-22") LEAD = "the core reaches memory." @@ -589,15 +664,28 @@ def check(name, got, want): _root = Path(_td) (_root / "to-dos").mkdir() stale = _root / "to-dos" / "ROADMAP.md" - stale.write_text('chain ... **v2.4.4 "Ignition"**, the current release\n') + stale.write_text('chain ... **v2.4.4 "Ignition"**, the current release\n', encoding=UTF8) check("chain tail: a stale `the current release` is refused", len(chains_needing_a_summary({}, _root, new)), 1) - stale.write_text('chain ... **v2.4.5 "Compass"**, the current release\n') + stale.write_text('chain ... **v2.4.5 "Compass"**, the current release\n', encoding=UTF8) check("chain tail: a current one is accepted", chains_needing_a_summary({}, _root, new), []) - stale.write_text('nothing here at all\n') + stale.write_text('nothing here at all\n', encoding=UTF8) check("chain tail: absence is not a finding", chains_needing_a_summary({}, _root, new), []) + # v3.0.1: the real documents are em-dash heavy UTF-8. Written as bytes + # so the test does not depend on the locale it is checking; under a + # non-UTF-8 locale a locale-default read raised UnicodeDecodeError here. + stale.write_bytes('chain — **v2.4.4 "Ignition"** — café, the current release\n' + .encode(UTF8)) + check("chain tail: UTF-8 is read as UTF-8 whatever the locale", + len(chains_needing_a_summary({}, _root, new)), 1) + + # v3.0.1: a failed cosim lockfile refresh is REPORTED, not swallowed. + # This temporary root has no `crates/rustynes-cosim/Cargo.toml`, so + # `cargo metadata` fails (or cargo is absent): either way a message. + check("cosim lock: a failed refresh returns an error", + bool(refresh_cosim_lock(_root)), True) # Android's versionCode (v3.0.0): the scheme, its ordering, and refusal of a # version it cannot encode without colliding. @@ -621,6 +709,14 @@ def check(name, got, want): got, n = bump_internal_requirements(manifest, "2.9.9") check("a minor bump leaves requirements alone", (n, got), (0, manifest)) + # The workspace version anchor must not match an internal requirement that + # happens to carry the same string (v3.0.1: every requirement read "3.0.0"). + ws = ('[workspace.package]\nversion = "3.0.0"\nedition = "2024"\n\n' + '[workspace.dependencies]\n' + 'rustynes-core = { path = "crates/rustynes-core", version = "3.0.0" }\n') + want = dict(MANIFESTS)["Cargo.toml"].format(v="3.0.0") + check("the Cargo.toml anchor matches the package line only", ws.count(want), 1) + # An unclassifiable line must raise, never be bumped mechanically. try: demote('**Current release: v2.4.4 "Ignition"** blah', "**Current release: v", old, new, LEAD) @@ -679,7 +775,7 @@ def main() -> int: for path, marker in anchors(root): p = root / path marker_index.setdefault(p, []).append((path, marker)) - text = edits.get(p, p.read_text()) + text = edits.get(p, p.read_text(encoding=UTF8)) out = [] for line in text.split("\n"): if marker + old.version in line: @@ -710,7 +806,7 @@ def main() -> int: for path, pat in MANIFESTS: p = root / path - text = edits.get(p, p.read_text()) + text = edits.get(p, p.read_text(encoding=UTF8)) want = pat.format(v=old.version, code=version_code(old.version)) if text.count(want) != 1: unknown.append(f"{path}: expected exactly one {want!r}, found {text.count(want)}") @@ -723,7 +819,7 @@ def main() -> int: # resolving. Every manifest that declares one is scanned, not a list, so a # crate added later cannot be missed. for p in [root / "Cargo.toml", *sorted((root / "crates").glob("*/Cargo.toml"))]: - text = edits.get(p, p.read_text()) + text = edits.get(p, p.read_text(encoding=UTF8)) text, moved = bump_internal_requirements(text, new.version) if moved: edits[p] = text @@ -777,12 +873,12 @@ def main() -> int: print("classified: " + ", ".join(f"{v} {k.lower()}" for k, v in counts.items() if v) + "\n") for p, text in sorted(edits.items()): - before = p.read_text() + before = p.read_text(encoding=UTF8) if before == text: continue rel = p.relative_to(root) if args.apply: - p.write_text(text) + p.write_text(text, encoding=UTF8) print(f" wrote {rel}") else: d = list(difflib.unified_diff(before.split("\n"), text.split("\n"), @@ -805,12 +901,16 @@ def report_owed() -> None: return 1 return 0 - try: - run(["cargo", "metadata", "--manifest-path", - "crates/rustynes-cosim/Cargo.toml", "--format-version", "1"], root) - print(" refreshed crates/rustynes-cosim/Cargo.lock") - except Exception as e: # noqa: BLE001 - print(f" WARNING: could not refresh the cosim lockfile: {e}", file=sys.stderr) + lock_err = refresh_cosim_lock(root) + if lock_err: + # The anchors above are already written; a stale cosim lockfile beside + # them is an incomplete bump, so this is a failure, not a WARNING. + print(f"\nERROR: could not refresh crates/rustynes-cosim/Cargo.lock: {lock_err}\n" + "Fix that and re-run `cargo metadata --manifest-path " + "crates/rustynes-cosim/Cargo.toml --format-version 1` before committing.", + file=sys.stderr) + return 1 + print(" refreshed crates/rustynes-cosim/Cargo.lock") # A chain whose tail says "the current release" cannot be extended by a # token swap: the new link needs a WRITTEN SUMMARY of the release, which # this script does not have and must not invent. Through v2.6.14 it said diff --git a/tests/roms/LICENSES.md b/tests/roms/LICENSES.md index 073f46c07..b216ee82f 100644 --- a/tests/roms/LICENSES.md +++ b/tests/roms/LICENSES.md @@ -162,7 +162,7 @@ deterministic frame-hash visual smoke (no input) in | `AccuracyCoin/sub-tests/frame-counter-irq.nes` | derived from `AccuracyCoin.asm` (suite 13 / test 2 — `TEST_FrameCounterIRQ`) | NROM (0) | derivative of Chris Siebert | MIT (inherits) | | `AccuracyCoin/sub-tests/apu-reg-activation.nes` | derived from `AccuracyCoin.asm` (suite 13 / test 6 — `TEST_APURegActivation`) | NROM (0) | derivative of Chris Siebert | MIT (inherits) | -The sub-test ROMs under `AccuracyCoin/sub-tests/` (26 in total; the four +The sub-test ROMs under `AccuracyCoin/sub-tests/` (33 in total; the four with dedicated Rust regression tests are tabulated above, and the rest follow the identical build + MIT-inheritance pattern) are derivative works produced by patching the upstream `AccuracyCoin.asm` source to @@ -188,7 +188,7 @@ AccuracyCoin is a single-NROM-cartridge battery of NES accuracy tests. The ROM i plus hex error codes) with no `$6000` status protocol. The integration test in `crates/rustynes-test-harness/tests/accuracycoin.rs` decodes the per-test result state from RAM and asserts the measured pass rate, which RustyNES holds at -**141/141 (100.00%)** (see `docs/STATUS.md`). +**144/144 (100.00%)** (see `docs/STATUS.md`). ## "full palette" ROMs @@ -280,10 +280,10 @@ none is commercial software. domain), Damian Yerrick Holy Mapperel variants (zlib, including the mapper 28 / 78.3 / 118 / 180 boards), and related homebrew — kept out of the actively-gated set but retained for manual investigation. -- `AccuracyCoin/sub-tests/` (26 `.nes`): the boot-into-one-test derivatives of +- `AccuracyCoin/sub-tests/` (33 `.nes`): the boot-into-one-test derivatives of `AccuracyCoin.asm` described above, all MIT (inheriting upstream). -The authoritative running total is 328 committed `.nes` files under +The authoritative running total is 338 committed `.nes` files under `tests/roms/` (per `git ls-files`; excluding the gitignored `tests/roms/external/` and any untracked clone contents); no commercial ROM is among them. diff --git a/tests/roms/assorted/README.md b/tests/roms/assorted/README.md index 775fd89da..952bbc8a8 100644 --- a/tests/roms/assorted/README.md +++ b/tests/roms/assorted/README.md @@ -25,9 +25,11 @@ corpus for that reason. ## Source -All files were copied from `christopherpow/nes-test-roms` (specific -upstream paths are in `tests/roms/LICENSES.md` "blargg's NES test ROMs" -section). +All files were copied from `christopherpow/nes-test-roms`. The specific +upstream paths are in the per-ROM rows of `tests/roms/LICENSES.md`: the +"blargg's NES test ROMs" section carries the CPU, branch-timing, OAM, reset +and APU ROMs, and the "full palette" ROMs section carries `full_palette.nes`, +`flowing_palette.nes` and `nestest.nes` (kevtris). ## What they test