Skip to content

RustyNES v2.3.5 — "Manifest" (what the core declares about itself — and the APU, measured at last)

Latest

Choose a tag to compare

@github-actions github-actions released this 16 Aug 03:57
40c66b9

RustyNES v2.3.5 "Manifest" is about what the emulator tells the outside world about itself. A user reported that RetroArch still showed RustyNES under the old MIT/Apache-2.0 licence. It did — and chasing that one line of metadata opened an audit of the whole libretro wrapper, which turned out to be misreporting five further things, every one of them with correct emulation sitting behind it. Alongside it, the APU — 18.7% of frame time and never once examined — finally got an instrument, and the optimization that instrument justified.

The emulation core's shipped OUTPUT is byte-identical, but the APU implementation did change: the default-configuration mix specialization is a strict specialization of rustynes-apu (mix() receives the same five arguments), not a no-op. So the accuracy contract was verified rather than asserted: AccuracyCoin holds at exactly 141/141, nestest is 0-diff.

RetroArch was advertising the wrong licence, and it still is

v2.2.9 relicensed RustyNES to GPL-3.0-or-later (ADR 0036) as a derivative work of GPL emulators. That reached Cargo.toml, NOTICE, deny.toml, the SPDX headers, the README — and the copy of rustynes_libretro.info in this repo.

RetroArch does not read that copy. It downloads dist/info/rustynes_libretro.info from libretro/libretro-super, an unrelated file that nothing syncs and nothing compared, which still says:

license         = "MIT OR Apache-2.0"
display_version = "v2.2.1"

Both upstream PRs merged 2026-07-21, exactly two weeks before the 2026-08-04 relicence, so no sync could ever have carried it. Given that this project's licence is itself the outcome of a corrected provenance failure, a frontend misreporting it is a compliance matter, not a cosmetic one.

This release fixes the repo-side half only. The metadata users actually see lives in a repository this project does not control, so nothing here changes what RetroArch currently displays; that completes when the upstream PRs merge, on libretro's schedule.

The local file was also wrong, if less so. libretro metadata does not use SPDX — it uses short tokens and marks "or later" with a trailing +. Tallied across all 316 upstream core files:

token cores
GPLv2 100
GPLv3 64
GPLv2+ 19
GPLv3+ 5
MIT OR Apache-2.0 1 — ours

So the correct token is GPLv3+; the bare GPLv3 carried since v2.3.0 understated RustyNES as GPL-3.0-only. That tally also confirmed the diagnosis independently — one file in 316 carried our token, and it was the stale one.

A standing audit now pins the local .info against two sources of truth, so the upstream sync is a copy rather than a re-derivation, and a licence change is documented as a mandatory sync trigger on the same footing as a release.

Five wrapper defects, five correct emulations behind them

Every one of these was a wrapper defect. In no case was the emulation wrong.

Defect Effect
Hardcoded 60.0988 fps for every cartridge, and retro_get_region never implemented PAL and Dendy ran 20.2% too fast, audio pitched to match
retro_reset never implemented RetroArch's Reset did nothing, ever
retro_unload_game never implemented Game Genie cheat indices leaked across cartridges
aspect_ratio = 0.0 RetroArch drew square pixels; the desktop app draws 8:7
No controller info, retro_set_controller_port_device a no-op The Zapper was unreachable despite being fully emulated

The Reset one is worth stating plainly: the hook was never implemented, so it inherited the library's default, which is literally a no-op. The menu entry and the hotkey both appeared to work and did nothing for the core's entire existence.

The pacing figures are now derived from rustynes_core's own FRAME_DURATION_* rather than transcribed — the 60.0988 literal had long since lost any connection to the constant it was copied from, which is why it could neither follow the core nor distinguish regions. A test pins that the NTSC derivation reproduces the old value exactly: moving it would be a pacing change for nearly every user, a larger problem than the bug being fixed.

The Zapper has always been emulated properly — Nes::set_zapper resolves the photodiode against the CRT beam — it simply had no route in. Ports 1 and 2 now offer it in the Controls menu; off-screen and reload reports are forwarded as a trigger pull at a guaranteed-dark position rather than dropped, which is how a real Zapper behaves when pointed away from the television, and the mechanism those games' shoot-off-screen behaviour depends on.

The crate had zero tests. It now has eight.

A use-after-free that compiled cleanly

Found in review. libretro.h does not specify pointer lifetimes for environment callbacks either way, so it was settled against RetroArch's actual handler:

memcpy(sys_info->ports.data, info, i * sizeof(*sys_info->ports.data));

That copies the outer retro_controller_info array, and the copy is shallow — each entry's types pointer is retained verbatim and dereferenced later, when the Controls menu is built. Built as locals, the description tables handed the frontend pointers into a stack frame that dies when the call returns.

They are static now. The outer array stays a local deliberately, because it is copied, and that asymmetry is documented at the call site so nobody later tidies the static away. The neighbouring set_input_descriptors call was checked rather than assumed, and is safe: RetroArch walks that array during the call and retains only 'static string pointers. Never generalise from one environment call to another.

The APU, measured at last

v2.3.1's per-source-file attribution put the APU at 18.7% of frame time, the largest core cost never examined — invisible to a symbol profile because fat LTO inlines it wholesale into cpu_clock, which is exactly why the ten-candidate v2.3.1 sweep never reached it. It had no throughput bench; the CPU and PPU both did.

It has one now, and 81% of the active per-cycle cost is paid with every channel disabled — the overhead is very largely unconditional. That is what the optimization went after: at the shipped default, a per-channel gate closure, a per-channel scale closure, a 6-wide array copy and a sixth mask test all evaluate to the identity, 1.789 million times a second. Hoisting the is-this-the-default question out of the per-cycle body is a strict specialization — mix() receives the same five arguments — so the output is byte-identical by construction, pinned anyway by a 2,048-point sweep.

−3.3% to −4.2% on nes_run_frame_nestest, across two full replicates.

What this release does not claim

  • The RetroArch licence display is not fixed for users. Two upstream PRs are required, in repositories this project does not control.
  • RustyNES still does not appear on iOS / iPadOS / tvOS, and this release cannot make it. The cause is now understood and is not a build failure: the buildbot carries a valid ios-arm64 core. iOS cannot download cores, so the App Store build bundles a hardcoded list in libretro/RetroArch's pkg/apple/update-cores.sh, and RustyNES is absent from it while every NES competitor is present. The remedy is a one-line upstream addition covering all three Apple platforms.
  • The APU optimization's mechanism is only partly understood, and the review made that worse rather than better. The nestest saving is roughly three times what the standalone bench can account for, and ~8× the flowing_palette saving — from a component doing identical work in both. Adopted on the measurement, not on the explanation.

Corrections to this project's own published numbers

  • The C0 instrument was wrong on first submission. It claimed to measure the APU "exactly as the bus drives it" and then ran only tick(), omitting the end-of-cycle DMC pair — ~23% of the true per-cycle cost. Both review bots caught it independently. Correcting it widened the unexplained gap in the C1 adoption from 2× to 3× rather than closing it; docs/performance.md records that outcome instead of re-fitting the story.
  • A published SFDR comparison was a probe artifact. A first measurement showed 44.1 kHz beating 48 kHz by 5.5 dB. At matched normalized frequency the two are equivalent (81.6 vs 82.2 dB) — the BLEP synthesizes at the target rate and the mixer designs its filters from fs, so the DSP is rate-agnostic. The declared rate stays 44,100, on the correct grounds: it is the only rate this project's audio is actually verified at, not because it measured better.
  • A guard test could not fail on its own bug. The fast-path fallback test compared two booleans and passed only because pulse 1 happened to be at output 0. It now compares drained audio, and was mutation-checked in both directions.

Full detail is in CHANGELOG.md, docs/performance.md, and docs/libretro/UPSTREAM_SYNC.md, which records the per-field sync table and the surfaces that must move together.