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