Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md

Large diffs are not rendered by default.

32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,38 @@ cycle-accurate core later replaced.

## [Unreleased]

## [2.2.7] - 2026-08-04 - "Timbre II" (expansion-audio fidelity: VRC6 + Sunsoft 5B)

An **expansion-audio accuracy** release addressing NESdev-forum feedback. Driven by a
measure-first cross-reference of VRC6 and Sunsoft 5B against **11 reference emulators**
(Mesen2/MesenCE, ares, higan, nestopia, fceux, tetanes, rustico, GeraNES, puNES, BizHawk)
plus the NESdev wiki — because a Mesen2-only comparison hides where Mesen2 itself is the
outlier. **The base 2A03 output is byte-identical** (these are expansion-only changes:
`mix_audio()==0` for non-expansion mappers), so **AccuracyCoin holds 141/141 (100.00%)**,
nestest is 0-diff, and blargg/kevtris are unchanged. The base BLEP decimator was
independently verified excellent (SFDR **81.6 dB**, `rustynes-apu` spectral test).

### Changed

- **VRC6 level recalibrated to the field/hardware consensus** — a full-volume VRC6 pulse
is now **≈1.0×** a 2A03 pulse (was ~1.506×). `VRC6_MIX_SCALE` 979 → 650. The prior
1.506× mirrored **Mesen2's specifically louder mixer convention** (Mesen2 weights VRC6
`×5`); a reviewer flagged VRC6 as too loud, and the cross-reference confirmed Mesen2 is
the loud outlier: the NESdev wiki says the VRC6 pulses are "roughly equivalent to the
pulse channels of the 2A03", and rustico / tetanes / BizHawk encode a VRC6 pulse == a
2A03 pulse *exactly* (ares/higan/nestopia reach the same via `sum/61`). The `db_vrc6a/b`
oracle target moved 1.506 → 1.000 and the two snapshots were re-blessed (audio-only —
framebuffer + cycle count byte-identical). VRC6's per-channel balance (linear
`pulse+pulse+saw`, saw 0–31 vs pulse 0–15) was already correct and is unchanged.
- **Sunsoft 5B envelope now uses the exact 5-bit 1.5 dB/step DAC** — the envelope-mode
amplitude path indexes a new 32-level `SUNSOFT5B_LOG_VOL32` table (×1.1885/step = +1.5 dB,
matching nestopia/rustico) at full 5-bit resolution, instead of truncating the envelope
to 4-bit (the wiki-named 3 dB approximation). Fixed 4-bit volume tones (already correct
3 dB/step) and the 5B absolute level (1.265×) are unchanged; the odd entries of the
32-level table equal the 4-bit table exactly (guarded by a new unit test). Extant 5B
test-ROM snapshots stay byte-identical; envelope-modulated 5B music now gets the exact
curve.

Comment thread
coderabbitai[bot] marked this conversation as resolved.
## [2.2.6] - 2026-08-04 - "Almanac" (de-monetization + provenance accuracy)

A **de-monetization and provenance** release. RustyNES is now permanently
Expand Down
34 changes: 17 additions & 17 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ members = [
default-members = ["crates/rustynes-libretro"]

[workspace.package]
version = "2.2.6"
version = "2.2.7"
edition = "2024"
rust-version = "1.96"
license = "MIT OR Apache-2.0"
Expand Down
15 changes: 10 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
</p>

<p align="center">
<a href="https://github.com/doublegate/RustyNES/actions"><img src="https://github.com/doublegate/RustyNES/workflows/CI/badge.svg" alt="Build Status"></a> <a href="#license"><img src="https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue.svg" alt="License: MIT OR Apache-2.0"></a> <a href="https://github.com/doublegate/RustyNES/releases"><img src="https://img.shields.io/badge/version-v2.2.6-blue.svg" alt="Version"></a> <a href="rust-toolchain.toml"><img src="https://img.shields.io/badge/rust-1.96-orange.svg" alt="Rust: 1.96"></a><br>
<a href="https://github.com/doublegate/RustyNES/actions"><img src="https://github.com/doublegate/RustyNES/workflows/CI/badge.svg" alt="Build Status"></a> <a href="#license"><img src="https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue.svg" alt="License: MIT OR Apache-2.0"></a> <a href="https://github.com/doublegate/RustyNES/releases"><img src="https://img.shields.io/badge/version-v2.2.7-blue.svg" alt="Version"></a> <a href="rust-toolchain.toml"><img src="https://img.shields.io/badge/rust-1.96-orange.svg" alt="Rust: 1.96"></a><br>
<a href="#compatibility-and-accuracy"><img src="https://img.shields.io/badge/AccuracyCoin-100%25%20(141%2F141)-brightgreen.svg" alt="AccuracyCoin"></a> <a href="#compatibility-and-accuracy"><img src="https://img.shields.io/badge/nestest-0--diff-brightgreen.svg" alt="nestest"></a> <a href="https://doublegate.github.io/RustyNES/"><img src="https://img.shields.io/badge/play-in%20browser-success.svg" alt="Try in browser"></a><br>
<a href="#platform-support"><img src="https://img.shields.io/badge/platform-Windows%20%7C%20Linux%20%7C%20macOS%20%7C%20Web%20%7C%20Android%20%7C%20iOS-lightgrey.svg" alt="Platform"></a>
</p>
Expand Down Expand Up @@ -795,9 +795,14 @@ and the Material-for-MkDocs documentation handbook at

## Current Release

RustyNES's current release is **v2.2.6 "Almanac"**, a **de-monetization and
provenance** release that opens the **v2.2.6 → v2.3.0** line addressing NESdev-forum
feedback. It carries **zero emulation-core behavior changes** — so **AccuracyCoin holds
RustyNES's current release is **v2.2.7 "Timbre II"**, an **expansion-audio fidelity**
release: VRC6 recalibrated to the field/NESdev consensus (a VRC6 pulse ≈ a 2A03 pulse,
~1.0× — Mesen2 was the loud outlier), and the Sunsoft 5B envelope moved to the exact 5-bit
1.5 dB/step DAC (matching nestopia/rustico). Expansion-only, so the base 2A03 output is
byte-identical (**AccuracyCoin 141/141**, nestest 0-diff); the base BLEP is a verified
81.6 dB-SFDR band-limited decimator. It builds on **v2.2.6 "Almanac"**, the
**de-monetization and provenance** release that opened the **v2.2.6 → v2.3.0** line
addressing NESdev-forum feedback. **v2.2.6** carried **zero emulation-core behavior changes** — so **AccuracyCoin holds
141/141 (100.00%)**, nestest is 0-diff, and the `#![no_std]` chip stack, save-state / TAS
/ netplay formats, and every golden vector are byte-identical by construction. What it
does:
Expand Down Expand Up @@ -1037,7 +1042,7 @@ If you use RustyNES in academic research, please cite:
author = {RustyNES Contributors},
title = {RustyNES: A Cycle-Accurate NES Emulator in Rust},
year = {2026},
version = {2.2.6},
version = {2.2.7},
url = {https://github.com/doublegate/RustyNES},
note = {Cycle-accurate NES emulator on a master-clock-precise scheduler;
AccuracyCoin 100\% (141/141), nestest 0-diff; 172 mapper families,
Expand Down
2 changes: 1 addition & 1 deletion crates/rustynes-mappers/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1569,7 +1569,7 @@ mod expansion_level_tripwire {

#[test]
fn expansion_audio_levels_are_pinned() {
assert_eq!(crate::m024_vrc6::VRC6_MIX_SCALE, 979, "VRC6: {RE_BLESS}");
assert_eq!(crate::m024_vrc6::VRC6_MIX_SCALE, 650, "VRC6: {RE_BLESS}");
assert_eq!(
crate::m005_mmc5::MMC5_PULSE_SCALE,
650,
Expand Down
55 changes: 33 additions & 22 deletions crates/rustynes-mappers/src/m024_vrc6.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,24 +54,34 @@ fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
/// Linear scale applied to the summed VRC6 channel output (see
/// [`Vrc6::mix_audio`]).
///
/// Calibrated so a single full-volume (15) VRC6 pulse reaches ~1.5x the
/// amplitude of a single full-volume 2A03 pulse — the level the bbbradsmith
/// `db_vrc6` decibel-comparison ROM and the Mesen2 mixer characterize (Mesen2
/// `NesSoundMixer::GetOutputVolume` weights VRC6 at `output * 5` against a
/// 2A03 pulse DAC of `95.88*5000/(8128/15+100) ≈ 746.9`, giving `15*15*5 /
/// 746.9 ≈ 1.506`). Concretely, one pulse toggling 0↔15 swings the mixer by
/// `15 * 979 = 14685` raw units; divided by the bus's `/65536` external-audio
/// normalization that is `0.2241`, versus the 2A03 pulse's `pulse_table[15] ≈
/// 0.1488` — a ratio of `1.506`. The full three-channel peak stays in range:
/// `(61 - 30) * 979 = 30349 < i16::MAX`, so a loud Akumajou-Densetsu / Madara
/// passage never clips. Before v2.1.6 this was `256` (≈0.39x the 2A03 pulse —
/// ~11.7 dB too quiet). See `docs/apu-2a03.md` §Expansion-audio levels.
/// Calibrated (v2.2.7 "Timbre II") so a single full-volume (15) VRC6 pulse
/// reaches ~**1.0x** the amplitude of a single full-volume 2A03 pulse — the
/// level the NESdev wiki ("at maximum volume, the pulse channels of the VRC6
/// are roughly *equivalent* to the pulse channels of the 2A03"), the bbbradsmith
/// `db_vrc6` decibel-comparison ROM's matched-level intent, and the wider
/// reference field all corroborate: rustico, tetanes, and BizHawk each encode a
/// VRC6 pulse == a 2A03 pulse *exactly*, and ares/higan/nestopia reach the same
/// target via a `sum/61` normalization. Concretely, one pulse toggling 0↔15
/// swings the mixer by `15 * 650 = 9750` raw units; divided by the bus's
/// `/65536` external-audio normalization that is `0.1488`, matching the 2A03
/// pulse's `pulse_table[15] ≈ 0.1488` — a ratio of `1.00`. The full
/// three-channel peak stays in range: `(61 - 30) * 650 = 20150 < i16::MAX`, so a
/// loud Akumajou-Densetsu / Madara passage never clips.
///
/// **History:** before v2.2.7 this was `979` (~1.506x the 2A03 pulse), which
/// mirrored Mesen2's specifically *louder* mixer convention (Mesen2 weights VRC6
/// at `output * 5` in `NesSoundMixer::GetOutputVolume`). A NESdev-forum reviewer
/// flagged the VRC6 balance as too loud; a cross-check against the whole
/// `ref-proj/` field (see the cross-reference in the v2.2.7 notes) confirmed
/// Mesen2 is the loud outlier and the field/hardware consensus is 1.0x. Before
/// v2.1.6 it was `256` (≈0.39x — ~11.7 dB too quiet). See `docs/apu-2a03.md`
/// §Expansion-audio levels.
///
/// `pub(crate)` so the NSF-playback path (`crate::nsf_expansion::Vrc6Exp::mix`)
/// references the SAME constant as the cartridge path — the two mixers can
/// never drift apart, guaranteeing an NSF VRC6 tune stays level-matched to a
/// VRC6 cartridge.
pub(crate) const VRC6_MIX_SCALE: i16 = 979;
pub(crate) const VRC6_MIX_SCALE: i16 = 650;

/// VRC6 audio pulse channel state (`$9000-$9002` for pulse 1, `$A000-$A002`
/// for pulse 2). Period is 12-bit, decrements every CPU cycle. On
Expand Down Expand Up @@ -548,11 +558,12 @@ impl Mapper for Vrc6 {
// Three channels: pulse1 (4-bit, 0..=15), pulse2 (4-bit, 0..=15),
// sawtooth (5-bit, 0..=31). Sum is in 0..=61.
//
// Per nesdev "VRC6 audio": the three channels are summed digitally,
// so a linear sum is the canonical mix. The [`VRC6_MIX_SCALE`] = 979
// factor makes a single full-volume pulse ~1.5x the 2A03 pulse (the
// hardware/Mesen2/`db_vrc6` level); the full three-channel peak
// `(61 - 30) * 979 = 30349` stays below `i16::MAX`.
// Per nesdev "VRC6 audio": the three channels are summed digitally
// (a 6-bit DAC over two 4-bit pulses + the high 5 bits of the saw), so a
// linear sum is the canonical mix. The [`VRC6_MIX_SCALE`] = 650 factor
// makes a single full-volume pulse ~1.0x the 2A03 pulse (the NESdev /
// field / `db_vrc6` consensus level); the full three-channel peak
// `(61 - 30) * 650 = 20150` stays below `i16::MAX`.
let p1 = i16::from(self.pulse1.output());
let p2 = i16::from(self.pulse2.output());
let saw = i16::from(self.saw.output());
Expand Down Expand Up @@ -831,20 +842,20 @@ mod tests {
// Tick once so the oscillator advances past the timer == 0 reload.
m.clock_audio();
let s = m.mix_audio();
// Centering subtracts ~30 from a 0..=61 sum, scales by 979 (v2.1.6).
// With only p1 = 15 contributing, s = (15 - 30) * 979 = -14685.
// Centering subtracts ~30 from a 0..=61 sum, scales by 650 (v2.2.7).
// With only p1 = 15 contributing, s = (15 - 30) * 650 = -9750.
assert!(s < 0, "mix_audio with only p1 must be below center");
}

#[cfg(feature = "mapper-audio")]
#[test]
fn vrc6_mix_audio_silent_when_disabled() {
let m = Vrc6::new(synth(8), synth_chr(8), 24, Mirroring::Vertical).unwrap();
// All channels disabled -> outputs 0 -> sum 0 -> mix = (0 - 30) * 979.
// All channels disabled -> outputs 0 -> sum 0 -> mix = (0 - 30) * 650.
// Confirm we land at the documented "center - offset" position.
let mut m = m;
let s = m.mix_audio();
assert_eq!(s, -29370);
assert_eq!(s, -19500);
}

#[test]
Expand Down
Loading