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
6 changes: 3 additions & 3 deletions AGENTS.md

Large diffs are not rendered by default.

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

## [Unreleased]

## [2.2.8] - 2026-08-04 - "Aperture II" (gamma-aware scanlines + sharper CRT)

A **presentation-fidelity** release addressing the NESdev-forum feedback on
gamma-aware resampling and bilinear-soft scanlines. **Presentation-only — nothing
here touches the emulation core**, so the pre-shader framebuffer, save-states, and
every golden vector are byte-identical (AccuracyCoin 141/141, nestest 0-diff), and
the **shipped native default is byte-identical** to v2.2.7 (the native sRGB
surface passes `aux = 0`, which selects the exact pre-v2.2.8 scanline profile;
the new linear-light + sharper-scanline path activates only for a non-zero
`aux`, set on the WebGL2 non-sRGB path and when the scanline knob is raised).
The base BLEP audio and the advanced CRT stacks
(royale/guest/megatron, already gamma-correct) are untouched.

> **Visual verification pending.** These are shader/appearance changes; naga
> validates that the WGSL compiles and the native/wasm builds are clean, but the
> on-screen result must be confirmed on a real display + a browser (WebGL2).

### Changed

- **Gamma-correct scanlines + aperture mask (base CRT pass, `CRT_WGSL`).** The
scanline/mask *darkening* now happens in **linear light**. On the native path
(sRGB texture + surface) the sampler/surface already convert, so the shader
leaves it linear (`aux.y = 0`, output byte-identical). On a plain UNORM path
(**WebGL2**, which does neither) the shader now sRGB-decodes on read and
re-encodes before output (`aux.y = 1`) — fixing a real browser-only gamma bug so
a scanline valley is 50% of the *linear* luminance, not the encoded value. The
round-trip uses the **exact IEC 61966-2-1 piecewise sRGB transfer** (the
`0.04045` / `0.0031308` breakpoints + a 2.4 exponent), not a `pow(2.2)`
approximation, so the WebGL2 result matches the hardware sRGB surface the native
path uses bit-for-bit.
- **Sharper scanlines (`aux.x`, default 0.5).** The scanline profile blends from
the original soft parabola (0) to a narrow Gaussian beam (1) for crisp vertical
boundaries instead of the linear-sampler blur — the sharper scanlines the
feedback asked for. Only visible when scanlines are enabled; `aux.x = 0`
reproduces the pre-v2.2.8 profile exactly. Wired on the desktop
(`rustynes-frontend`), Android (`rustynes-android`), and iOS Metal
(`rustynes-ios`) hosts via the shared 16-float CRT uniform
(`rect + crop + params + aux`) — all three set `aux` identically for the
scanline/CRT filters, so the corrected profile is consistent across platforms.

## [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
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.7"
version = "2.2.8"
edition = "2024"
rust-version = "1.96"
license = "MIT OR Apache-2.0"
Expand Down
11 changes: 8 additions & 3 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.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="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.8-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,7 +795,12 @@ and the Material-for-MkDocs documentation handbook at

## Current Release

RustyNES's current release is **v2.2.7 "Timbre II"**, an **expansion-audio fidelity**
RustyNES's current release is **v2.2.8 "Aperture II"**, a **presentation-fidelity**
release: gamma-correct scanlines (linear-light darkening + a WebGL2 gamma fix) and a
sharper Gaussian scanline profile for crisp vertical boundaries. Presentation-only —
the pre-shader framebuffer is byte-identical (**AccuracyCoin 141/141**) and the shipped
native default is unchanged; the shader/appearance changes await on-display + browser
visual verification. It builds on **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
Expand Down Expand Up @@ -1042,7 +1047,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.7},
version = {2.2.8},
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
20 changes: 17 additions & 3 deletions crates/rustynes-android/src/gfx.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ struct Uniforms {
crop: [f32; 4],
// CRT: (scanline, mask, _, _). NTSC: (saturation, sharpness, tint, phase).
params: [f32; 4],
// NTSC only: x = PAL mode. Padding for the CRT shader (which reads 12 floats).
// CRT (v2.2.8): x = scanline sharpness, y = gamma-linearize flag.
// LMP-NTSC: x = PAL mode. Bisqwit: picture knobs.
aux: [f32; 4],
}

Expand Down Expand Up @@ -441,10 +442,23 @@ impl AndroidGfx {
(1.0, screen_aspect / IMG_ASPECT) // letterbox
};
// For CRT/scanline/LMP-NTSC the `params` come straight from the per-filter
// sliders (aux unused). For Bisqwit (filter 4) `params.x` is the per-frame
// videoPhase and the picture knobs (contrast/sat/bright/hue) ride in `aux`.
// sliders. For Bisqwit (filter 4) `params.x` is the per-frame videoPhase and
// the picture knobs (contrast/sat/bright/hue) ride in `aux`.
//
// v2.2.8 "Aperture II": the CRT/scanline pass (filters 1, 2) now reads
// `aux.x` = scanline sharpness and `aux.y` = a gamma-linearize flag (1 on a
// plain UNORM surface so the shader keeps the darkening in linear light; 0 on
// an sRGB surface where the sampler/surface already convert). Filters 0 (none)
// and 3 (LMP-NTSC) keep `aux = 0` (LMP-NTSC's PAL flag rides its own path).
let crt_linearize = if self.config.format.is_srgb() {
0.0
} else {
1.0
};
let (params, aux) = if self.filter == 4 {
([f32::from(self.ntsc_phase), 0.0, 0.0, 0.0], self.params)
} else if self.filter == 1 || self.filter == 2 {
(self.params, [0.5, crt_linearize, 0.0, 0.0])
} else {
(self.params, [0.0, 0.0, 0.0, 0.0])
};
Expand Down
26 changes: 24 additions & 2 deletions crates/rustynes-frontend/src/crt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,16 @@ pub struct CrtFilter {
scanline: f32,
/// Aperture-mask intensity (fixed-subtle for the MVP).
mask: f32,
/// v2.2.8 "Aperture II": scanline beam sharpness (aux.x, 0 = the original soft
/// parabola .. 1 = a crisp Gaussian beam for sharp vertical boundaries). Only
/// visible when `scanline > 0`.
sharpness: f32,
/// v2.2.8: gamma round-trip flag (aux.y). `1.0` when the surface is a plain
/// UNORM (e.g. WebGL2) so the shader must sRGB-decode/encode around the
/// scanline+mask darkening to keep it in linear light; `0.0` on an sRGB
/// surface where the sampler + surface already handle the gamma conversion
/// (so the shipped native output stays byte-identical).
linearize: f32,
}

impl CrtFilter {
Expand Down Expand Up @@ -216,13 +226,19 @@ impl CrtFilter {
});
let scanline = scanline.clamp(0.0, 1.0);
let mask = 0.10; // subtle fixed grille for the MVP.
// v2.2.8: crisper scanlines by default (only visible when scanline > 0), and
// the gamma round-trip flag derived from the surface's color space (sRGB ->
// 0 = leave linear; plain UNORM/WebGL2 -> 1 = decode/encode in the shader).
let sharpness = 0.5;
let linearize = if surface_format.is_srgb() { 0.0 } else { 1.0 };
let uniforms = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("crt-uniforms"),
// rect (identity letterbox) + crop (none) + params (scanline, mask).
// rect + crop + params (scanline, mask, rows, _) + aux (sharpness, linearize, _, _).
contents: bytemuck::cast_slice(&[
1.0f32, 1.0, 0.0, 0.0, // rect
1.0, 0.0, 1.0, 0.0, // crop (v-scale, v-off, u-scale, u-off)
scanline, mask, 0.0, 0.0, // params
sharpness, linearize, 0.0, 0.0, // aux
]),
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
});
Expand Down Expand Up @@ -251,6 +267,8 @@ impl CrtFilter {
bind_group,
scanline,
mask,
sharpness,
linearize,
}
}

Expand All @@ -274,7 +292,7 @@ impl CrtFilter {
par_correction: bool,
overscan: crate::config::Overscan,
) {
// rect (4) + crop (4) from the shared helper, then the CRT params (4).
// rect (4) + crop (4) from the shared helper, then params (4) + aux (4).
let lb = crate::gfx::letterbox_uniform(width, height, par_correction, overscan);
let uniform = [
lb[0],
Expand All @@ -289,6 +307,10 @@ impl CrtFilter {
self.mask,
0.0,
0.0,
self.sharpness,
self.linearize,
0.0,
0.0,
];
queue.write_buffer(&self.uniforms, 0, bytemuck::cast_slice(&uniform));

Expand Down
Loading
Loading