From e49711c14bc3e868193d8b577a1f35934384cb49 Mon Sep 17 00:00:00 2001
From: DoubleGate
Date: Sun, 12 Jul 2026 11:27:31 -0400
Subject: [PATCH] release: cut v2.2.0 "Capstone" (netplay lobby + FDS medium
model + peripherals + quality/security)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The milestone cut that closes the v2.1.5 -> v2.2.0 "deepen the existing
project" run, landing its two remaining marquees plus a peripherals and
quality/security pass. Every change already merged (PRs #290, #291) is
additive or default-off; the deterministic core is untouched, so this cut
itself moves only version strings + release docs and no framebuffer/audio
byte.
Marquees (already on main via #290/#291):
- Netplay matchmaking + lobby (B5) — a browse-and-join room directory
(`ListRooms`/`RoomList`) and server-side quick-play (`QuickMatch`/`Matched`)
over the existing room-code / TURN transport, delayed-stream spectators
(`SpectatorConfig.delay_frames`, reveal-horizon gated), a graded
hysteresis desync verdict (`DesyncStatus`, threshold 3 ~= 1.5 s), and
graded multi-second peer-liveness RTT timeouts (`PeerLink` +
`DisconnectReason::PeerTimeout`). Signaling and telemetry only — the
rollback/determinism contract is untouched.
- FDS medium model completion (F4.3) — per-block CRC-16/KERMIT re-emitted on
every BIOS write (`resynth_block_crc`) over a synthesized gap/mark wire
image, an opt-in default-OFF continuous analog head-seek / velocity model
(`Fds::set_analog_head_seek`) replacing the flat `HEAD_RESEEK_CYCLES`
window, and a BIOS-free synthetic write-verify oracle
(`Fds::medium_write_verify`) as the CI-verifiable half (the real-BIOS
write-CRC path stays gitignored/local — copyright disksys.rom). Additive
v4 FDS save-state tail; with the model off a non-writing `.fds` run is
byte-identical to prior releases.
Peripherals (#291): the Famicom `$4016`-bit-2 microphone
(`Nes::set_microphone`, `$4016`-only, never `$4017`) and a 3x3-aperture
Zapper light-timing model (`ZAPPER_APERTURE_*`), both additive/default-off.
Quality/security (#290): cargo-fuzz targets grown 3 -> 8 (`ppu_reg_io`,
`apu_reg_io`, the high-value `netplay_message`, `save_state`, `movie`),
which surfaced and fixed two real OOM-DoS paths in `Movie::deserialize`
(untrusted `frame_count` -> unbounded `Vec::with_capacity`, and a
zero-`bytes_per_frame` infinite push loop) byte-identically for valid input;
a read-only Tools -> ROM Info browser (dump-identity CRC32 keys + SHA-256 +
per-game DB entry + decoded header, `&Nes`, never mutates); and four new
MkDocs handbook pages.
Cut mechanics: `[workspace.package] version` 2.1.10 -> 2.2.0 (Cargo.lock
refreshed across all 18 workspace crates), CHANGELOG `[Unreleased]` renamed
to `[2.2.0]` with a fresh empty `[Unreleased]`, `.github/release-notes/
v2.2.0.md` authored, and README badge / BibTeX / current-release paragraph
updated. `release-auto.yml` tags + publishes on main going green.
Sacred gate (verified on the merged #290+#291 tree): AccuracyCoin 141/141
(100.00%), nestest 0-diff, `pal_apu_tests` 10/10, `visual_regression`
byte-identical, netplay rollback-determinism + snapshot-restore-replay +
FDS synthetic write-verify green, `no_std` thumbv7em cross-compile, full
host fmt/clippy/rustdoc/markdownlint, version consistency.
Co-Authored-By: Claude Opus 4.8
---
.github/release-notes/v2.2.0.md | 41 +++++++++++++++++++++++++++++++++
CHANGELOG.md | 2 ++
Cargo.lock | 36 ++++++++++++++---------------
Cargo.toml | 2 +-
README.md | 34 +++++++++++++++------------
5 files changed, 81 insertions(+), 34 deletions(-)
create mode 100644 .github/release-notes/v2.2.0.md
diff --git a/.github/release-notes/v2.2.0.md b/.github/release-notes/v2.2.0.md
new file mode 100644
index 00000000..47a25a02
--- /dev/null
+++ b/.github/release-notes/v2.2.0.md
@@ -0,0 +1,41 @@
+# RustyNES v2.2.0 — Capstone
+
+The milestone cut that **closes the v2.1.5 → v2.2.0 "deepen the existing project" run**, landing its two remaining marquees — the **netplay matchmaking/lobby** stack and the **FDS medium model** — alongside a peripherals + quality/security pass. Every change is additive or default-off: the deterministic core is untouched, so **AccuracyCoin holds 141/141 (100.00%)**, `visual_regression` is byte-identical, nestest is 0-diff, `pal_apu_tests` 10/10, and save-state / TAS / netplay replay stay bit-identical. Two PRs (#290, #291).
+
+## Added
+
+### Netplay matchmaking + lobby, spectators, desync/liveness hardening (B5) — #290
+
+- **Lobby + matchmaking** — the pure signaling protocol (`crates/rustynes-netplay/src/signaling.rs`) grows a **browse-and-join** lobby directory and a matchmaking path atop the existing room-code / TURN stack. New `SignalMessage` variants — `ListRooms { rom_hash }` → `RoomList { rooms }` (open, joinable, optionally game-filtered rooms; each `RoomInfo` carries code / player-count / capacity / `rom_hash` and *no* SDP/ICE/identity), and `QuickMatch { rom_hash, max_players }` → `Matched { room, slot, max_players }` (server-side quick-play: join any open room for the ROM via the shared `add_to_room` primitive, or mint a fresh `QM-NNNNNN` room). The `room-list` frame is parsed by a brace-depth walk bounded at `MAX_ROOM_LIST` (256) so an oversized frame cannot force an unbounded allocation. Determinism / rollback contract untouched — signaling only.
+- **Delayed-stream spectators** — `SpectatorConfig.delay_frames` (clamped to `MAX_DELAY_FRAMES` = 512 ≈ 8.5 s) layers an intentional broadcast / anti-spoiler / jitter-smoothing hold atop the natural spectator lag: frame `f` is revealed only once frame `f + delay_frames` is confirmed (`reveal_horizon()`). Purely a *presentation* delay — frames are still produced byte-identically and in order and the spectator still sends nothing — so it cannot perturb the match. Wired to `NetplayUi::spectator_delay_frames` (default 0).
+- **Hardened desync surface** — `DesyncDiagnostics` gains a single graded `DesyncStatus` { `InSync` / `Suspect` / `Desynced` } verdict with a hysteresis threshold (`DEFAULT_DESYNC_THRESHOLD` = 3 consecutive mismatches ≈ 1.5 s at the 30-frame checksum interval) so a lone reordered / late peer checksum no longer flashes a false desync banner, plus a sticky peak-run rule so a confirmed desync never silently downgrades. Pure telemetry over the `NetMessage::Checksum` digests the session already exchanges.
+- **Peer-liveness RTT timeouts** — a graded `PeerLink` { `Live` / `Interrupted` / `TimedOut` } for an already-synced `NetplayConnection`, driven by `last_recv` against `peer_interrupt_timeout` (2 s) / `peer_disconnect_timeout` (5 s), plus a terminal `DisconnectReason::PeerTimeout`. Deliberately far above Mesen's trigger-happy ~150 ms: a single lost 1 Hz `Quality` ping or a routine Wi-Fi/LTE retransmit spike never trips it, matching the multi-second grace windows GGPO/Parsec use. Both thresholds are builder-configurable (`with_peer_timeouts`).
+
+### FDS medium model completion — CRC-16 / gap / continuous head-seek (F4.3, a marquee) — #291
+
+- The Famicom Disk System RAM adapter (`crates/rustynes-mappers/src/fds.rs`) completes the disk **medium** model. The disk is a synthesized byte-stream wire image — lead-in / inter-block gaps, a `$80` start mark, the block bytes, and a **CRC-16/KERMIT** per block — and each BIOS-written block now **re-emits a fresh per-block CRC-16** over its updated payload (`resynth_block_crc`), modelling the RP2C33 controller's continuous CRC generator so the medium stays self-consistent after a write.
+- A new **continuous analog head-seek / velocity model** (opt-in, default-OFF — `Fds::set_analog_head_seek`) replaces the flat fixed `HEAD_RESEEK_CYCLES` motor-restart not-ready window with a belt-driven, distance-proportional seek time (`HEAD_SEEK_BYTES_PER_CYCLE` velocity + `HEAD_SEEK_SETTLE_CYCLES` settle, clamped to a cold spin-up), sized from the head-travel distance captured at motor-off.
+- A **BIOS-free synthetic write-verify oracle** (`Fds::medium_write_verify`) walks the wire image and asserts every block's CRC-16 and gap/mark framing round-trips — the CI-verifiable half of the medium model; the real-BIOS write-CRC path needs a copyright `disksys.rom` and runs only from a gitignored local dump (`docs/accuracy-ledger.md` records the CI-verifiable-vs-local-only split). **Additive and deterministic**: with the head-seek model off (the default) a non-writing `.fds` run is **byte-identical** to prior releases; the new state round-trips an additive **v4** FDS save-state tail (v1/v2/v3 blobs load with the model disabled). AccuracyCoin has no FDS ROM, so **141/141** is unaffected.
+
+### Peripherals — Famicom microphone + Zapper light-timing (peripherals) — #291
+
+- The Famicom built-in controller-2 **microphone** is modelled on **`$4016` bit 2** (`Nes::set_microphone` / `Bus::set_microphone`), wired through the frontend input path (hold-to-talk `M` key → `FrameInputs.microphone` → latch), for games such as *The Legend of Zelda* (Pols Voice) and *Kid Icarus*. It is a `$4016`-only signal (never touches `$4017`).
+- The **Zapper** photodiode now integrates a **3×3 aperture** (field-of-view) around the aim point, asserting light only when ≥2 pixels cross the luma threshold (`ZAPPER_APERTURE_*`) — hardening detection against sub-pixel aim error and PPU edge noise vs the prior single-pixel sample, while staying a deterministic pure function of the presented framebuffer (no save-state change). Both are additive and **default-off**.
+
+### Quality / security — fuzz expansion, ROM Info browser, handbook (quality) — #290
+
+- **Fuzz-target expansion** — `fuzz/` grows from 3 to 8 cargo-fuzz targets covering the remaining untrusted-input boundaries: `ppu_reg_io`, `apu_reg_io`, `netplay_message` (the highest-value target — `NetMessage::from_bytes` binary UDP + `SignalMessage::parse` JSON signaling/lobby, both ingesting bytes straight off the wire), `save_state` (`parse_header` + `extract_thumbnail` + `restore_quiet`), and `movie` (`Movie::deserialize`). Each builds under nightly cargo-fuzz and runs clean for tens of thousands of iterations. The `movie` target **found two real OOM-DoS paths** (see Changed).
+- **Read-only ROM Info browser** — a new **Tools → ROM Info** panel surfaces, for the loaded ROM, the two dump-identity CRC32 keys (the header-excluded game-DB key + the full-file **No-Intro** key), the SHA-256, the effective per-game database entry, and the decoded cartridge header read straight off the running `Nes`. Read-only (`&Nes`) — never mutates the emulator or the DB overlay; the deterministic core never consults it.
+- **MkDocs handbook deepening** — four new Material-for-MkDocs pages (`docs/expansion-audio.md`, `docs/pal-region.md`, `docs/crt-composite.md`, `docs/creator-tools.md`) — curated entry points for the newer subsystems, cross-linked to the authoritative specs, with matching `mkdocs.yml` nav entries.
+
+## Changed
+
+- **Movie (`.rnm`) deserializer hardening** — the new `movie` fuzz target surfaced two OOM-DoS paths in `Movie::deserialize` (`crates/rustynes-core/src/movie.rs`), both now fixed **byte-identically for valid input**: (1) the untrusted 4-byte `frame_count` was passed straight to `Vec::with_capacity`, so a 49-byte header could claim a multi-gigabyte reservation — now capped at `remaining_bytes / width`; (2) a `bytes_per_frame` of 0 made each `r.take(0)` consume no input, so the frame loop pushed `frame_count` empty records out of a finite file — now rejected up front. Regression test `deserialize_hostile_frame_count_does_not_oom` added; the existing 44 movie tests (incl. the determinism round-trip) stay green.
+
+## Notes
+
+- Version: workspace `2.1.10 → 2.2.0` (Cargo.lock refreshed). No dependency or mobile changes (the mobile apps re-release at v2.3.0).
+- **No default-build behavior change.** Netplay lobby / spectators / desync / liveness are signaling + telemetry only; the FDS head-seek model and both peripherals are default-OFF; the ROM Info browser is read-only. AccuracyCoin stays **141/141 (100.00%)**, `visual_regression` byte-identical, `pal_apu_tests` 10/10, and save-state / TAS / netplay replay stay bit-identical (verified by the netplay rollback-determinism + snapshot-restore-replay + FDS synthetic write-verify gates).
+- **Review-surfaced fixes landed alongside:** a netplay **liveness-spoofing** fix (`last_recv` refreshes only for the adopted peer's source, so a third party can no longer suppress `PeerTimeout`); `QuickMatch` now scans `self.rooms` uncapped (was capped at `MAX_ROOM_LIST`, contradicting its own contract); the FDS DMC-conflict `$4016` read now ORs the mic bit (`mic << 2`); and the FDS `load_disk_tail` offset carries a `debug_assert_eq!` full-consume invariant.
+- Docs updated in-change: `docs/frontend.md`, `docs/mappers.md`, `docs/accuracy-ledger.md`, the four new handbook pages, `fuzz/README.md`, `CHANGELOG.md`.
+- Full host gate green: fmt · clippy (native all-targets + `scripting` / `scripting,hd-pack` / `retroachievements` + all wasm32 targets) · rustdoc (`-D warnings`) · markdownlint (pinned v0.39.0) · AccuracyCoin 141/141 + `visual_regression` byte-identical + nestest 0-diff + `pal_apu_tests` 10/10 + the netplay determinism / FDS write-verify suites · `no_std` `thumbv7em` cross-compile · all 8 cargo-fuzz targets build · version consistency.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d602250c..d9266b3b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,8 @@ cycle-accurate core later replaced.
## [Unreleased]
+## [2.2.0] - 2026-07-12 - "Capstone" (milestone cut — netplay matchmaking/lobby + FDS medium model + peripherals + quality/security pass)
+
### Added
- **Netplay lobby + matchmaking (v2.2.0 "Capstone", B5).** The pure signaling protocol (`crates/rustynes-netplay/src/signaling.rs`) grows a **browse-and-join** lobby directory and a matchmaking path atop the existing room-code / TURN stack. New `SignalMessage` variants — `ListRooms { rom_hash }` → `RoomList { rooms: Vec }` (the open, joinable, optionally game-filtered rooms; each `RoomInfo` carries the code / player count / capacity / `rom_hash` and *no* SDP/ICE/identity), and `QuickMatch { rom_hash, max_players }` → `Matched { room, slot, max_players }` (server-side "quick play": join any open room for the ROM via the shared `add_to_room` primitive, or create a fresh one with a deterministic `QM-NNNNNN` code). The `room-list` JSON array is parsed by a brace-depth walk bounded at `MAX_ROOM_LIST` (256) so an oversized frame cannot force an unbounded allocation. Determinism/rollback contract untouched — this is signaling only.
diff --git a/Cargo.lock b/Cargo.lock
index 6c06c45c..d1f9185e 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4248,7 +4248,7 @@ dependencies = [
[[package]]
name = "rustynes-android"
-version = "2.1.10"
+version = "2.2.0"
dependencies = [
"android-activity",
"android_logger",
@@ -4266,7 +4266,7 @@ dependencies = [
[[package]]
name = "rustynes-apu"
-version = "2.1.10"
+version = "2.2.0"
dependencies = [
"bitflags 2.13.0",
"criterion",
@@ -4279,7 +4279,7 @@ dependencies = [
[[package]]
name = "rustynes-cheevos"
-version = "2.1.10"
+version = "2.2.0"
dependencies = [
"cc",
"ureq",
@@ -4287,7 +4287,7 @@ dependencies = [
[[package]]
name = "rustynes-core"
-version = "2.1.10"
+version = "2.2.0"
dependencies = [
"bitflags 2.13.0",
"criterion",
@@ -4304,7 +4304,7 @@ dependencies = [
[[package]]
name = "rustynes-cpu"
-version = "2.1.10"
+version = "2.2.0"
dependencies = [
"bitflags 2.13.0",
"criterion",
@@ -4315,7 +4315,7 @@ dependencies = [
[[package]]
name = "rustynes-frontend"
-version = "2.1.10"
+version = "2.2.0"
dependencies = [
"anstyle",
"arboard",
@@ -4369,11 +4369,11 @@ dependencies = [
[[package]]
name = "rustynes-gfx-shaders"
-version = "2.1.10"
+version = "2.2.0"
[[package]]
name = "rustynes-hdpack"
-version = "2.1.10"
+version = "2.2.0"
dependencies = [
"lewton",
"png",
@@ -4384,7 +4384,7 @@ dependencies = [
[[package]]
name = "rustynes-ios"
-version = "2.1.10"
+version = "2.2.0"
dependencies = [
"bytemuck",
"cpal",
@@ -4398,7 +4398,7 @@ dependencies = [
[[package]]
name = "rustynes-libretro"
-version = "2.1.10"
+version = "2.2.0"
dependencies = [
"libc",
"rust-libretro",
@@ -4407,7 +4407,7 @@ dependencies = [
[[package]]
name = "rustynes-mappers"
-version = "2.1.10"
+version = "2.2.0"
dependencies = [
"bitflags 2.13.0",
"criterion",
@@ -4419,7 +4419,7 @@ dependencies = [
[[package]]
name = "rustynes-mobile"
-version = "2.1.10"
+version = "2.2.0"
dependencies = [
"rustynes-core",
"rustynes-hdpack",
@@ -4434,14 +4434,14 @@ dependencies = [
[[package]]
name = "rustynes-monetization"
-version = "2.1.10"
+version = "2.2.0"
dependencies = [
"uniffi",
]
[[package]]
name = "rustynes-netplay"
-version = "2.1.10"
+version = "2.2.0"
dependencies = [
"futures-util",
"js-sys",
@@ -4457,7 +4457,7 @@ dependencies = [
[[package]]
name = "rustynes-ppu"
-version = "2.1.10"
+version = "2.2.0"
dependencies = [
"bitflags 2.13.0",
"criterion",
@@ -4469,14 +4469,14 @@ dependencies = [
[[package]]
name = "rustynes-ra"
-version = "2.1.10"
+version = "2.2.0"
dependencies = [
"rustynes-cheevos",
]
[[package]]
name = "rustynes-script"
-version = "2.1.10"
+version = "2.2.0"
dependencies = [
"mlua",
"piccolo",
@@ -4487,7 +4487,7 @@ dependencies = [
[[package]]
name = "rustynes-test-harness"
-version = "2.1.10"
+version = "2.2.0"
dependencies = [
"insta",
"png",
diff --git a/Cargo.toml b/Cargo.toml
index db4c3a12..8c263c95 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -21,7 +21,7 @@ members = [
]
[workspace.package]
-version = "2.1.10"
+version = "2.2.0"
edition = "2024"
rust-version = "1.96"
license = "MIT OR Apache-2.0"
diff --git a/README.md b/README.md
index 8559a69a..ba5244d3 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@
-

+


@@ -775,19 +775,23 @@ and the Material-for-MkDocs documentation handbook at
## Current Release
-RustyNES's current release is **v2.1.10 "Fathom" ("Loom")**, the creator-tools-and-web-parity
-step of the v2.1.5 → v2.2.0 "deepen the project" run — on the **creator** side it deepens
-**TAStudio** (greenzone / force-greenzone caching so piano-roll seeking is instant, named
-markers, richer branch slots) and broadens the **Lua API** (the full lifecycle-callback surface,
-CPU/PPU/palette/OAM/CHR read-write with side-effect-free `*Debug` variants, HUD drawing, an
-example-script library, and wasm-`piccolo` parity uplift); on the **web-parity** side it
-completes the **browser RetroAchievements** path (wasm trampoline marshalling + a deployable
-auth-proxy stack, ADR 0015) and brings **Vs. `DualSystem`** presentation to the **libretro**
-core. The deterministic core is untouched — greenzone snapshots + Lua reads/writes preserve
-the TAS/replay contract — so **AccuracyCoin stays 141/141 (100.00%)**, `visual_regression` is
-byte-identical, and save-state / TAS / netplay replay stay bit-identical. (Vs. `DualSystem` on
-the wasm present path is honestly deferred, and the browser-RA live login is a documented
-deploy-to-verify step — see the CHANGELOG.)
+RustyNES's current release is **v2.2.0 "Capstone"**, the milestone cut that **closes the
+v2.1.5 → v2.2.0 "deepen the existing project" run**, landing its two remaining marquees — the
+**netplay matchmaking/lobby** stack (a browse-and-join room directory + server-side quick-play
+over the existing room-code / TURN transport, delayed-stream spectators, a graded
+hysteresis-based desync verdict, and multi-second peer-liveness RTT timeouts — all signaling
+and telemetry only) and the **FDS medium model** (per-block **CRC-16/KERMIT** re-emitted on
+every BIOS write + a synthesized gap/mark wire image, an opt-in continuous analog head-seek /
+velocity model, and a BIOS-free synthetic write-verify oracle) — alongside a **peripherals**
+pass (the Famicom `$4016`-bit-2 microphone and a 3×3-aperture Zapper light-timing model, both
+default-off) and a **quality/security** pass (cargo-fuzz targets grown 3 → 8, which surfaced and
+fixed two real OOM-DoS paths in the `.rnm` movie deserializer; a read-only ROM Info browser;
+and four new MkDocs handbook pages). Every change is additive or default-off and the
+deterministic core is untouched, so **AccuracyCoin holds 141/141 (100.00%)**, `visual_regression`
+is byte-identical, `pal_apu_tests` is 10/10, and save-state / TAS / netplay replay stay
+bit-identical. It follows **v2.1.10 "Fathom" ("Loom")**, the creator-tools-and-web-parity step
+(TAStudio greenzone + Lua API breadth + browser RetroAchievements + Vs. `DualSystem` libretro
+presentation).
The v2.1.x line opened with **v2.1.0**, the accuracy-remediation release (a display-only
PPU palette-backdrop-override fix, 86 mapper families promoted BestEffort → Curated, and
@@ -913,7 +917,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.1.10},
+ version = {2.2.0},
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,