Skip to content

release: v3.6.0 "Octave" - #631

Draft
doublegate wants to merge 38 commits into
mainfrom
release/v3.6.0
Draft

doublegate wants to merge 38 commits into
mainfrom
release/v3.6.0

Conversation

@doublegate

@doublegate doublegate commented Oct 29, 2026 •

Copy link
Copy Markdown
Owner

v3.6.0 "Octave"

The user-facing account is .github/release-notes/v3.6.0.md and the [3.6.0] CHANGELOG section; this body is the reviewer's map. The codename is provisional.

Built on v3.5.1 (#630), which must merge first. Until it does, this PR's diff also shows v3.5.1's 7 commits. After v3.5.1 is squash-merged, main gets merged into this branch so the diff shows only v3.6.0.

Breaking

  • Emulation epoch 8 (v3.5.1: 7), for the NSF playback fixes and the noise pitch: v3.5.1 movies and netplay peers are refused with a message naming both epochs.
  • libretro save states grow by 16 bytes to record the emulation options. Older states still load.
  • MiSTer save states from v3.5.x are refused (doublegate/RustyNES_MiSTer#71).

Listen before merging

1491d3a1 changes the noise channel in every game: it now shifts every table period in CPU cycles, as NESdev documents, about an octave above every earlier release. No test ROM measures this. The pitch was measured against Nestopia's libretro core run as a black box: at periods 7, 8, 10 and 12, both shift every 160, 202, 380 and 762 CPU cycles (v3.5.1: 322, 406, 762, 1,526). Mesen's core crashed under the test frontend and was not measured. Nobody has listened to it yet: compare a few games' drums and explosions against hardware or a trusted emulator.

What landed (23 commits and 7 merges of the feature branches, by area)

  • Audio: 1491d3a1 noise LFSR period (with PAL's second period, 7 -> 8); 7541d77a stem export and trace-assisted NSF ripping; 60a03280 stems staged and renamed into place.
  • NSF: 2106a2a6 play runs every frame at 60 Hz again (frame IRQ inhibited, NMI enabled after the PPU warm-up); 7dfd1988 the driver moved off the MMC5 status port; 38366602 epoch 8.
  • Debugger: 87ac335e rustynes-dap, a Debug Adapter Protocol server and a loopback-only GDB stub (ADR 0049); 190b7378 cc65 and llvm-mos fixture builds and a CI job.
  • libretro: 0ad500c1 Core Options v2 with a v0 fallback; 7d69818a Vs. DIP switches; 85d8780b overclock and sprite limit, kept in states; 113b1298 float audio negotiation (int16 stays the fallback); ca585fe4 the .info audit reads every core module; a9fa8f45 Switch, Vita and 3DS recorded as no-go.
  • Mobile: a3c464c6 Android API 37, the Nearby-devices permission for LAN play, paced non-blocking audio; 569fed41 iOS durable writes and a once-a-second FDS disk check.
  • Docs and release: cdc8218d, fd745cf0, acd79252 (ADR 0050, full-speed PAL and Dendy on MiSTer), b3811760, 719e079d the cut, ecda19e9.
  • Correction: 5cd47db3 re-records 558 coverage-suite audio checksums that 1491d3a1 missed (it said 112 was all of them). Each changed file differs from the previous version only in audio_fnv1a64.

For the maintainer

  • Provenance question (not certified either way): before writing rustynes-dap's cc65 reader, the agent that built it had read the desktop source-map module, which carries a Mesen2 Provenance: header. It wrote the reader from ld65's output and cc65's documentation and put an origin disclosure at the top of the file, not a Provenance: header. Whether that needs a header, a provenance record and a NOTICE entry is your call.
  • T-CABINET-OVERCLOCK-DECAY-STATE: a Vs. DualSystem state does not round-trip byte for byte under the CPU overclock (64 bytes of inert OAM decay ages). Pinned by an expected-fail probe, not fixed.

Verification (local, on the cut tree)

  • cargo test --workspace --features test-roms,commercial-roms --no-fail-fast: 4,030 passed, 0 failed, 20 ignored after 5cd47db3. The first run was 4,029/1/20, the failure being those 558 checksums. Commercial suites 60/0, 169/0, 6/0.
  • Workspace 3,412/0/11; fmt, clippy for every feature set, both wasm32 builds and rustynes-cosim, rustdoc -D warnings, the no_std core: all clean.
  • Release audits: anchors 18/0, state prose 9/0, notes rendering 4/0, contribution checklist 14/0, libretro .info 6/0, epoch fingerprint 2/0. pre-release.sh: nothing definitively wrong.

Not done, on purpose

  • Not yet checked: Android and iOS on devices (Android ran on two API 37 emulators only), a Swift compile, a real editor or GDB/LLDB attached to rustynes-dap (no released GDB or LLDB has a MOS target), RetroArch's float-audio path.
  • No MiSTer bitstream compiled (release time), no hardware run, no PAL yc.txt entry.

🤖 Generated with Claude Code

https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj


Note

High Risk
Emulation epoch 8 and widespread audio checksum changes affect determinism, movies, and netplay; noise pitch changes every game that uses the channel, and libretro/MiSTer save-state layout changes can break cross-version workflows.

Overview
This release cuts v3.6.0 "Octave" (and documents v3.5.1 "Reprise" in CHANGELOG/release notes): workspace version 3.5.0 → 3.6.0, new rustynes-dap crate in the workspace, and EMULATION_EPOCH 8 so v3.5.1 movies/netplay are refused.

Core / audio: The noise channel is fixed to shift at NESdev-documented CPU-cycle periods (~octave higher than before); hundreds of audio checksum goldens are re-recorded. NSF playback is fixed (60 Hz play every frame; MMC5 driver relocated off $5015).

New tools: rustynes-dap (DAP + read-only GDB stub for cc65/llvm-mos), rustynes stems, and rustynes nsf-rip, with a new CI job dap-fixtures that rebuilds test ROMs from pinned cc65/llvm-mos and runs end-to-end tests.

libretro: Core Options v2, float audio when the frontend supports it, CPU overclock / sprite limit / Vs. DIP options, and +16 bytes in save states for emulation options (older states still load). MiSTer v3.5.x save states are refused (PAL/Dendy layout change — sibling repo).

Mobile: Android targetSdk 37 with ACCESS_LOCAL_NETWORK for LAN netplay and non-blocking audio pacing; iOS durable atomic writes and periodic FDS disk flush (Swift not compiled in this cut).

Docs, README, AGENTS, SECURITY, and related files are updated to current release v3.6.0; Android versionCode 30600.

Reviewed by Cursor Bugbot for commit cc30c0a. Bugbot is set up for automated code reviews on this repo. Configure here.

doublegate and others added 30 commits October 23, 2026 21:24
T-LIBRETRO-OPTIONS-V2, v3.6.0 plan row 1.

Mechanism. The options now live in one table, `options::OPTIONS`
(new module `crates/rustynes-libretro/src/options.rs`): key, label,
categorised label, sublabel, category and `(value, label)` pairs with
the default first. `options::declare` asks
`RETRO_ENVIRONMENT_GET_CORE_OPTIONS_VERSION`; at 2 or more it sends
`SET_CORE_OPTIONS_V2` (a category table plus definitions, each array
terminated by an all-null entry, each values list by a null value),
otherwise the legacy `SET_VARIABLES` ("desc; default|other|..."), which
is what every frontend got before. Both declarations are generated from
the same table at first use into process-lifetime `OnceLock`s, so the
pointers the frontend keeps never dangle and the two forms cannot
disagree about keys, values or defaults. `on_options_changed` reads every
key through `GET_VARIABLE` into one `OptionValues`; an unanswered or
unknown value reads as the default.

Traps, from libretro.h rather than assumption:
- On a version-2 frontend `SET_CORE_OPTIONS_V2` "is guaranteed to
  succeed" and its return value only says whether categories are shown,
  so `false` is NOT treated as a refusal and does not fall back to v0.
- A version-1 frontend gets v0, not v1: v1 adds sublabels but no
  categories, and every v1 frontend accepts v0, so a third declaration
  form would add surface for nothing.
- A frontend that does not answer the version query reads as version 0
  (`rust-libretro`'s `get_core_options_version`) and gets v0.

Compatibility. Keys never change once shipped, because a frontend stores
the user's choice by key. The v3.5.0 option `rustynes_four_score` keeps
its key, its values and its v0 string byte for byte ("Four Score
(players 3 and 4); disabled|enabled"), pinned by a unit test and by the
ABI test below. `CoreVariables`/`CORE_VARIABLES` are gone; the
`four_score` field is now `options.four_score`.

Verification (worktree, 2026-10-23):
- `cargo test -p rustynes-libretro`: 57 passed, 0 failed (53 before:
  +3 `options` unit tests, +1 ABI test).
- New ABI test `core_options_v2_with_a_v0_fallback_keep_every_key`: the
  fake frontend now answers `GET_CORE_OPTIONS_VERSION` (2 by default, a
  current RetroArch) and records `SET_CORE_OPTIONS_V2` definitions and
  categories; the test runs an init cycle at versions 2, 1 and "no
  answer" and checks v2-only / v0-only / v0-only, that both forms declare
  the same keys including every v3.5.0 key with its v3.5.0 v0 string,
  and that each v2 definition has a declared category, a label, a
  sublabel, labelled values and the same values and default as its v0
  string. `GET_VARIABLE` in the fake now answers any key a test sets.
- Mutants (each applied alone, test re-run, file restored from a
  snapshot): never v2 -> CAUGHT; v2 at every version -> CAUGHT; Four
  Score v0 description shortened -> CAUGHT; Four Score key renamed ->
  CAUGHT (two tests); v2 value labels nulled -> CAUGHT. 5 of 5.
- `cargo fmt --all --check`; `cargo clippy -p rustynes-libretro
  --all-targets -- -D warnings` clean. The workspace and feature gates
  run at the end of the branch.

Not done here: the new options themselves (overclock, sprite limit, Vs.
DIP switches) and float audio, each in its own commit after this one.
No real RetroArch was driven; the harness stands in for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
v3.6.0 plan row 3. The core applied the Vs. database's factory DIP
switches (v2.9.0, NL-08) and nothing could change them: the desktop has
a config override, the libretro core had no option, and its docs said
the switches did not fit the `SET_VARIABLES` form. With Core Options v2
in place (previous commit) they get a "Vs. System" category.

Mechanism. Eight options, `rustynes_vs_dip_1` .. `_8` (switch n = bit
n-1 of `Nes::set_vs_dip`), each `database` (default), `off` or `on`.
`OptionValues` carries them as a forced-switch mask plus the forced
positions; `dip_for(base)` = `(base & !mask) | (value & mask)`, so a
switch left at "Game default" keeps the database's bit. At load the core
records each console's factory bank (`vs_dip_base`, after
`apply_vs_database`: the cartridge or a cabinet's main console, and the
sub console), and `apply_console_options` holds the console to the
options at the top of every frame, compare-then-set and only on a Vs.
console, so a console at the defaults is never written and output stays
byte-identical. `apply_cabinet_options` does both cabinet consoles, each
against its own bank. Eight three-value options rather than one list
because a values list holds at most 127 entries
(`RETRO_NUM_CORE_OPTION_VALUES_MAX` = 128, less the terminator).

A first draft also applied the options at load; that call was dead (the
per-frame apply runs before any emulation, and the bank is not in a save
state), its mutant survived, and it was removed rather than tested.

Verification (worktree, 2026-10-23):
- `cargo test -p rustynes-libretro`: 60 passed, 0 failed. New:
  `abi_tests::a_vs_dip_option_reaches_the_cabinet` (the probe cartridge
  copies `$4016`/`$4017` to WRAM; switches 1, 3, 8 on and 2 off must read
  as a bare console with bank 0x85 reads them, the "database" setting and
  no option must equal bank 0, and a cabinet's main console must read
  the same switch bits), `tests::both_consoles_of_a_cabinet_get_the_dip_options`
  (the sub console's bank, which no frontend-visible memory shows), and
  `options::tests::dip_overrides_replace_only_the_forced_switches`.
- The 60 include the two local-dump tests: `tests/roms/external` was
  linked into the worktree from the main checkout (gitignored, read
  only), and `a_vs_dump_in_the_database_renders_with_its_palette` passed
  with the options at their defaults.
- Mutants: bank never written -> CAUGHT; `dip_for` returns the base ->
  CAUGHT; "on" sets no bit -> CAUGHT; sub console skipped -> CAUGHT; the
  cabinet path never applies -> CAUGHT; the sub against the main's base
  -> CAUGHT; the factory bank not recorded at load -> CAUGHT only by
  `a_vs_dump_in_the_database_renders_with_its_palette`, which needs a
  gitignored Vs. dump: in CI the committed corpus has none and that test
  checks nothing, so in CI this mutant survives. 7 of 7 locally, 6 of 7
  in CI.
- fmt, `cargo clippy -p rustynes-libretro --all-targets -D warnings`.

Docs: `docs/libretro/advanced_features.md` (the "Not done" bullet is
replaced by what is done and what pins it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
From v2.9.8 to v3.5.x a standard-rate NSF never had its play routine
called: the player ran init and nothing else, so a tune played only what
init wrote. Two defects in the synthetic driver at $5000, found by the
NSF ripper's playback gate (T-AUDIO-STEMS), which plays a ripped tune
back and compares its APU writes with the game's:

1. The driver's first instruction after init was STA $2000 (#$80) to
   enable the vblank NMI. v2.9.8 began modelling the PPU's post-reset
   warm-up, in which writes to $2000/$2001/$2005/$2006 are dropped for
   29,658 CPU cycles; the driver runs well inside that window, so the
   write was dropped, PPUCTRL stayed $00 and no NMI ever came. Measured
   on a ripped tune: 0 NMI entries in 200,000 instructions, PPUCTRL $00.
2. It then executed CLI with the APU frame IRQ enabled (the power-on
   $4017 is $00, 4-step with the IRQ live). The IRQ vector points at an
   RTI stub that never acknowledges it, so the CPU spent the rest of the
   run re-entering that stub: 95,030 IRQ entries in the same 200,000
   instructions.

The driver now follows nesdev's "NSF" page (a player writes $40 to $4017,
4-step with the frame IRQ inhibited, before init) and "PPU power up
state" (wait out the warm-up with two $2002 vblank polls before enabling
the NMI):

  5000 SEI; CLD; LDX #$FF; TXS
  5005 LDA #song          (operand still @ +6: set_song is unchanged)
  5007 LDX #$40; STX $4017
  500C LDX #region; JSR init
  5011 JMP $5024
  5015 NMI entry (unchanged)
  5023 IRQ RTI stub (unchanged)
  5024 BIT $2002; BPL; BIT $2002; BPL; LDA #$80; STA $2000; CLI; spin

The vector targets and the song operand keep their offsets, so track
selection and save states are unaffected. init still runs after the
$4017 write, so a tune that selects 5-step mode keeps its choice. The
50 Hz / custom-rate timer driver already inhibited the frame IRQ and
needs no NMI; it is unchanged and was never affected. Cartridges and FDS
do not reach this code.

Output: NSF playback changes (it now plays). No golden, snapshot or
epoch-fingerprint probe covers a 60 Hz NSF (the existing tone test's
play is an RTS), so nothing moves in the oracle; whether the change
should raise EMULATION_EPOCH for NSF movies is left to the release cut.

Tests:
- nsf_standard_rate_calls_play_every_frame (rustynes-core), pinned RED
  first ("play ran 0 times in 120 frames"), green after: play increments
  $00, 114..=118 calls in 120 frames, and the frame IRQ flag is never
  raised.
- driver_vectors_point_into_driver (rustynes-mappers) updated for the
  JSR init operand's new offset and checks the $4017 write.
- Mutants: NOP-ing the two vblank polls fails the new test (0 calls);
  NOP-ing the $4017 write was first NOT CAUGHT (the NMI still reaches
  play while the CPU storms in the IRQ stub between frames), so the test
  gained the frame-IRQ assertion, which then caught it.

Verified: cargo test -p rustynes-mappers --lib 1,111 passed;
cargo test -p rustynes-core --features debug-hooks,hd-pack --lib 309
passed (with the stems/ripper modules present in the tree, uncommitted
here); clippy -p rustynes-core -p rustynes-mappers --all-targets
(debug-hooks, hd-pack) -D warnings clean; cargo fmt --check clean.

Not done: the MMC5 expansion window. NsfExpansion::cpu_read answers
$5015 (the MMC5 status port) before the driver image is consulted, and
$5015 is also the driver's NMI entry, so an MMC5 NSF's NMI appears to
fetch the status byte as its first opcode. Read from the code, not
reproduced; reported, not changed here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
…ates

T-LIBRETRO-EMU-OPTIONS (round 2 audit LIB-R2-02, deferred to v3.6.0).
The desktop's v3.1.0 options, the CPU-multiplier overclock
(`Nes::set_cpu_overclock`) and the sprite-limit removal
(`Nes::set_sprite_limit_disabled`), were not reachable from a libretro
frontend. They change what a movie replays and what a netplay peer runs,
so the ticket asks the core to record and apply them as the desktop does.

Options. `rustynes_cpu_overclock` (values 1-4, labelled "x1 (stock)" to
"x4"; a const assertion ties the list to `MAX_CPU_OVERCLOCK`) and
`rustynes_remove_sprite_limit` (disabled|enabled), in a new "Emulation"
category. A value outside 1-4 reads as 1. `apply_console_options` holds
the console (both consoles of a cabinet) to them at the top of every
frame, compare-then-set, as the desktop's `produce_frame` does; at the
defaults nothing is written.

The record. How the desktop carries them: `HardwareOptions` in the
`.rnm` header and `config_digest` in the netplay handshake. A libretro
frontend has neither: RetroArch replays a movie from a state and starts
a netplay peer from the host's state. So every state now ends with a
16-byte record (`RNLROPTS`, version 1, flags = Four Score | sprite
limit removed, overclock, DIP mask, DIP positions, 3 zero bytes) and
`retro_serialize_size` includes it. `retro_unserialize` splits it off
(`options::split_state`), applies it with the state, and logs when it
differs from the menu's; the menu's values come back at the next option
change the frontend reports. RetroArch's netplay CRC check now also sees
mismatched options. The Four Score and the DIP switches ride in the same
record because `HardwareOptions` carries them too.

Trap found while writing it: the overclock must be set BEFORE the
restore. The BUS section carries the position inside the overclocked
cycle pattern, `SystemBus::restore` clamps it to what the running
multiplier could produce, and `set_cpu_overclock` resets it on a change,
so setting it afterwards (the per-frame apply alone) loses the phase: the
mutant that does that fails the byte-for-byte replay test. Setting it
first means a refused restore must put the multiplier back too, and the
core's own rollback (taken after the change) cannot, so `restore_console`
/ `restore_cabinet` keep a backup of the running machine, only when the
multiplier changes (a user's load, never run-ahead's per-frame restore).

Compatibility. A state written before v3.6.0 has no record (its last 16
bytes are zero padding, or a cabinet's section data) and loads exactly as
before, leaving the options alone. A recognised record holding a value no
menu offers (overclock 0 or 9, a DIP position outside its mask, an unknown
flag or version, a non-zero reserved byte) refuses the state. States grow
by 16 bytes.

FOUND, NOT FIXED (core, outside this wrapper): a Vs. DualSystem cabinet's
state, saved and restored at x2 with no option change, differs ten frames
on from the straight run's in 64 bytes, the two consoles' 32 OAM-decay
ages each (`dot_counter / 3 - timestamp`), each one larger. Exact at x1,
and a single console's slim state is exact at x2. The decay model is off,
so the game is unaffected (the counter matches), but a CRC-comparing
frontend would see a desync. Pinned by the ignored expected-fail probe
`a_cabinet_state_round_trips_byte_for_byte_under_the_overclock`.

Verification (worktree, 2026-10-23):
- `cargo test -p rustynes-libretro`: 69 passed, 0 failed, 1 ignored (the
  probe; `--ignored` runs it and it fails with 64 bytes, as recorded).
- New ABI tests: `the_cpu_overclock_option_gives_the_game_more_cpu_time`
  (a counting cartridge, 8 cycles per step: x2 counts 1.95-2.05 times as
  far in ten frames, x4 3.9-4.1, x1 again equals stock),
  `the_sprite_limit_option_draws_every_sprite_on_a_line` (16 sprites on
  line 104: 64 lit pixels stock, 128 with the option),
  `the_default_options_leave_the_output_byte_identical` (unanswered and
  explicit defaults both give a bare console's frame and counter),
  `a_state_carries_its_emulation_options` (an x2 + sprite-limit state
  restored into a stock core continues byte for byte as the straight x2
  run, on a cartridge; on a cabinet, the counter matches; the log names
  both options), `states_without_a_record_load_and_bad_records_refuse`.
  New unit tests: record round trip and every refusal, the overclock
  parse, and the two restore helpers' rollback below the C ABI (the
  multiplier is configuration, re-set by the next frame, so only there
  is a wrong one visible).
- Mutants, 16, each alone with the file restored from a snapshot: 16
  CAUGHT. The first pass left three NOT CAUGHT (failure path keeps the new
  multiplier; a cabinet console skipped before the restore, main or sub)
  and a fourth (cabinet rollback skipping its backup) on the second; the
  two unit tests and the cabinet before/after comparison were added for
  them and each was re-run CAUGHT.
- fmt, `cargo clippy -p rustynes-libretro --all-targets -D warnings`,
  markdownlint on the changed docs.

Docs: `docs/libretro/implementation_specifics.md` (the options, the
record, the finding), `advanced_features.md`. CHANGELOG: Added and
Changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
v3.6.0 plan row 2 (T-LIBRETRO-OPTIONS-V2's audio half). The mixer
produces f32; the core converted every sample to i16 for the frontend,
which (RetroArch) widens it straight back to float.

Where the definition came from. `RETRO_ENVIRONMENT_GET_AUDIO_SAMPLE_BATCH_FLOAT`
is not in the vendored header (`vendor/rust-libretro-sys/libretro.h`,
the 2020-era copy `rust-libretro-sys` 0.3.2 ships; grep finds no FLOAT
command). Upstream's `libretro-common/include/libretro.h` in
`libretro/RetroArch` has it, added by 28f4c9c6a361 ("audio: add
RETRO_ENVIRONMENT_GET_AUDIO_SAMPLE_BATCH_FLOAT float output
negotiation", 2026-06-29), read at master on 2026-10-23 (the
libretro-common repo's copy has it too). The command number
(`85 | RETRO_ENVIRONMENT_EXPERIMENTAL`), `retro_audio_sample_batch_float_t`
and `struct retro_audio_sample_float_callback` are transcribed into
lib.rs as Rust declarations, with the header's MIT notice holder
credited at the site, in NOTICE and in docs/originality-and-provenance.md
section 5. Interface definitions only; no code. The vendored sys crate
is not regenerated (it would need bindgen against a 340 KB header for
one command).

Mechanism. `negotiate_float_audio` asks once, at the end of
`retro_load_game` (libretro.h: "Perform negotiation once, during
retro_load_game()"), and keeps the callback only if the answer is
`true` AND the pointer is set. `push_audio` then interleaves the mono
samples to stereo f32 (`sample_to_f32`: full scale 1.0 as the i16 path,
clamped to the [-1, 1] the header requires, NaN as 0.0, which is what
the i16 cast gives) into a pooled buffer and calls the float callback,
with no i16 call for that game ("must not mix int16 and float batch
calls"). Unload drops the callback (valid "until retro_unload_game");
the next load asks again. The fast-forward skip covers both paths.

No RetroArch release offers the command (1.22.2, 2025-11, predates it),
so in use today every frontend gets the i16 stream, byte for byte as
before.

Verification (worktree, 2026-10-23):
- `cargo test -p rustynes-libretro`: 71 passed, 0 failed, 1 ignored.
- `float_audio_when_offered_and_the_int16_stream_unchanged_when_not`:
  a tone cartridge (pulse 1, constant volume) for 20 frames against a
  bare console's drained samples. Not offered: no float call and the
  i16 stream equals `sample_to_i16` of the bare console's samples,
  duplicated. A frontend that fills the struct but answers false: the
  same i16 stream. Offered: no i16 call and the floats equal the bare
  console's samples, duplicated. Withdrawing the offer mid-game keeps
  float for that game; the next load is i16 again.
- `float_samples_keep_the_int16_scale_and_saturate_the_same_way`.
- Mutants (8): never negotiated, float never used, frames doubled, mono
  not stereo, `false` answer ignored, no clamp, negotiation call removed
  after the refactor -> all CAUGHT ("answer ignored" and "no clamp"
  survived the first pass: the fake frontend could not answer false with
  a filled pointer, and the filter missed the unit test; the refusing
  frontend case was added). Callback kept across unload -> NOT CAUGHT,
  equivalent: after unload no game runs, so nothing is pushed, and the
  next load overwrites it before its first frame. A redundant clear at
  the start of load was removed rather than left untested.
- fmt, clippy (-D warnings) on the crate, markdownlint on changed docs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
T-MMC5-LATCH-VBLANK, found in the v3.5.0 sibling review as a question
about both cores: the split's $5202 bank (`split_chr_bank_latch`) and an
ExGrafix tile's 4 KiB bank (`ex_chr_bank_latch`) are latched at each
background nametable fetch, and `chr_offset` applied whichever was set to
EVERY non-sprite CHR access. Nothing clears them when a frame ends, so a
$2007 read of CHR in vertical blank, or with rendering switched off in
$2001, read the bank of the last tile drawn instead of the bank
registers.

The NESdev MMC5 page (nesdev_wiki/MMC5.xhtml) settles which side is
right. It describes both overrides as substitutions to background
fetches while the PPU renders: "When the PPU is rendering in the split
region, the MMC5 replaces the normal nametable data", and in extended
attribute mode "CHR banking behaves differently than normal when
fetching background tiles from pattern tables". For $2007 it gives a
different rule: "the last set of registers written to (either
$5120-$5127 or $5128-$512B) will be used for I/O via PPUDATA ($2007)".
The page also names the software that depends on it: Bandit Kings of
Ancient China and Uchuu Keibitai SDF "have non-pattern data stored in
CHR ROM, read out via $2007", and the Koei games "use extended
attributes continuously". So the oracle was wrong, not the page.

The fix gates both overrides on the predicate the model already uses to
tell a rendering fetch from a $2007 access: `render_window &&
ppu_rendering` (the background-fetch window opened at dot 0 of the
pre-render and each visible line and closed at vertical blank, with the
snooped $2001 render enables), the one `non_sprite_set` uses for the 8x16
B-set choice. Outside it the access falls through to
`chr_set_offset(addr, non_sprite_set(read))`, the page's $2007 set. The
latches stay set (the next frame's fetches overwrite them before use);
only their application is gated.

The trap avoided: gating on `in_frame` instead looks equivalent and is
wrong. The page's in-frame flag is set by the first scanline detection,
near dot 4 of visible line 0, but line 0's first two tiles are
prefetched on the pre-render line's dots 321-336, before it, so those
tiles would lose their ExGrafix bank. `render_window` opens at the
pre-render line's dot 0 and covers the prefetch.

Tests, written red first (both failed on the pre-fix code at the
assertion after vertical blank / after $2001 = 0):
- an_exgrafix_latch_does_not_reach_a_vblank_chr_read: an ExGrafix tile's
  bank applies while rendering (1 KiB bank 12) and not after
  notify_vblank, where the read matches the unsubstituted set; the
  fixture asserts the two banks differ so the test cannot pass vacuously.
- a_split_latch_does_not_reach_a_chr_read_outside_rendering: the split's
  bank applies while rendering, not with $2001 rendering off mid-frame,
  again when rendering resumes, and not after vertical blank.
Five existing tests made background fetches with no rendering state at
all (a fresh mapper); they now enter rendering first, which is what
they model.

Does it move anything recorded? Measured, not argued:
- test-roms + commercial-roms, run on this change: external_real_games
  60/0 (Castlevania III, Bandit Kings and Uchuu Keibitai SDF among
  them), external_extended 169/0, external_coverage 6/0,
  epoch_fingerprint 2/0, mmc5 3/0, mmc5_scanline_irq 4/0. No snapshot
  and no fingerprint moved.
- A temporary probe (removed before this commit; the file was restored
  from a snapshot) counted every CHR access where the pre-fix latch would
  have chosen a different bank, over 3,600 frames of each of those three
  games with Start and A pulsed every 240 frames: 0, 0 and 0.
So EMULATION_EPOCH stays 6: the rule's trigger is a moved golden or
snapshot, and none moved. The residual risk is stated rather than
hidden: a v3.5.0 movie of a Koei game that reaches a screen reading CHR
through $2007 in vertical blank after an extended-attribute frame would
replay differently; none of the measured play reaches one.

Verified: rustynes-mappers lib 1,113 passed, 0 failed; clippy -D
warnings on rustynes-mappers --all-targets; the thumbv7em no_std build of
rustynes-core. docs/mappers.md's MMC5 CHR-set paragraph states the rule
and what changed. The sibling's mmc5.sv has the same defect and is fixed
on its own branch against this commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
T-LIBRETRO-CONSOLES (LR-04), v3.6.0 plan row 4. A go/no-go per target
with what blocked it, and the v2.9.8 libnx reason re-checked rather than
assumed. Result: no-go for all three; no buildbot job added.

How it was checked (2026-10-23):
- Rust: the nightly platform-support table and each target's page.
  aarch64-nintendo-switch-freestanding: tier 3, std "*" (no_std only).
  armv7-sony-vita-newlibeabihf: tier 3, std supported (VITASDK).
  armv6k-nintendo-3ds: tier 3, std "?" (partial; devkitARM plus the
  pthread-3ds / shim-3ds crates). `rustc +1.99.1 --print target-list`
  lists all three.
- libretro: listed libretro-infrastructure/ci-templates (69 files) on
  git.libretro.com. Rust templates exist for Android, Apple, iOS, Linux
  x64/i686/aarch64, macOS, webOS and Windows only; the console templates
  (libnx-static, vita-static, ctr-static) are C `make platform=<x>` jobs
  in devkitPro / VitaSDK images, linking the core's .a into RetroArch's
  static build. Read the four image Dockerfiles: no console image has
  Rust, and the Rust image has no console SDK.
- Builds of the staticlib in a scratch target dir with the installed
  nightly (1.101.0-nightly 7189d39d9 2026-10-22) and -Zbuild-std; the
  pinned toolchain was not touched; devkitARM/devkitA64/VitaSDK are not
  installed, so nothing was linked or run:
  * 3DS, std,panic_unwind: compiles (5 min 33 s), 50 retro_* exports.
  * Vita, std,panic_unwind: bindgen first rejects the triple ("version
    'newlibeabihf' ... is invalid"); with
    BINDGEN_EXTRA_CLANG_ARGS_armv7_sony_vita_newlibeabihf=--target=armv7a-none-eabihf
    it compiles (5 min 48 s), 50 retro_* exports.
  * Switch, std,panic_abort: bindgen rejects the triple; with an
    aarch64-none-elf override, std itself fails to compile (cfg_select!
    finds no allocator / io-error module; "Using no_threads
    implementation on a target with threads"). v2.9.8's reason holds,
    now from a build instead of from reading.
  The unresolved symbols of the two staticlibs are listed in the doc
  (libc/file calls, pthread_*, the EHABI unwinder, and on the 3DS
  getrandom and __aeabi_read_tp).

Why Vita and 3DS are no-go although they compile: no template or image
with Rust and the SDK (an upstream infrastructure change, and the
buildbot only runs on a push), a nightly-only build against the
project's stable pin and the libretro-cross equality gate, an untried
link, and speed. The speed argument is a reasoned expectation, NOT a
measurement, and the doc says so: ~4 ms/frame on the i9-10850K dev host
(docs/performance.md) needs a CPU at most ~4x slower; 268-444 MHz ARM11
/ Cortex-A9 cores are 10-20x slower by clock alone. What would reopen
it is written down: a measured device frame time, an image with both
toolchains, and a link into RetroArch's static build.

Docs: docs/libretro/architecture.md (dated section; item 5 points to
it), to-dos/plans/v3.6.0-plan.md (results), CHANGELOG [Unreleased]
Documentation. markdownlint on the three files. Docs only, so
SKIP=cargo-clippy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
Two headless tools, `rustynes stems` and `rustynes nsf-rip`, over two new
core modules behind `debug-hooks`: `rustynes_core::stems` and
`rustynes_core::nsf_rip`. Spec and user page: docs/audio-stems.md.

Stems: one emulation pass, re-rendered offline
----------------------------------------------
The emulation runs once, unchanged, with the APU's existing audio
provenance armed (v2.3.7's per-CPU-cycle MixRecord: the five raw channel
outputs and the raw expansion sample). StemCapture reads each frame's
trace and pushes every source ALONE through a private copy of the
console's output pipeline: the same lookup-table Mixer, a BlipBuf built
at the same sample rate and CPU clock (PAL's on a PAL image, NTSC for
NTSC/Dendy/Multi, mirroring Apu::new) and the same FilterModel. Nothing
is added to the APU's per-cycle work and no APU, bus or CPU code changed,
so the default synthesis is byte-identical by construction (the
audio_expansion insta gate, 25 passed, confirms the snapshots did not
move); scripts/perf/ab_check.sh had no hot-path change to measure.

Eight streams, all the same length: the console mix, pulse1, pulse2,
triangle, noise, dmc, expansion, interaction. The 2A03 mixer is
non-linear (pulse_table[p1+p2] + tnd_table[3t+2n+d], both concave), so a
stem is defined as its channel alone through that same mixer
(pulse_table[p1], tnd_table[2n], ...), and the seventh track is the
interaction term mixed - (sum of the six alone). The band-limited buffer
and the IIR chain are linear, so mix = sum of the seven to f32 rounding.

Trap found and handled: the reset sequence ticks the APU 8 cycles inside
the Nes constructor, before a trace can be armed, and the first run_frame
after power-on returns without running (the reset leaves the frame flag
set). The capture replays those 8 cycles from the channels' power-on
outputs (no register written yet), refuses to start past 64 pre-cycles
(StartedLate) and refuses a frame run without capture (Gap). Without the
replay all 47,121 samples of a one-second run differed from the console.

Checks, measured:
- The mix re-rendered from the trace equals the console's drained audio
  bit for bit: 0 mismatches on NROM (all five channels), PAL + Famicom
  filter, VRC6, VRC7, N163, MMC5, 5B (the vendored bbbradsmith db_*
  ROMs, 650 frames), a synthetic FDS, an NSF, and after a power cycle.
  The CLI reports it and exits 4 if it is ever non-zero.
- Each source stem equals the console run with only that channel's
  mask bit set (Nes::set_apu_channel_mask, then a power cycle so the
  reset cycles run masked too), sample for sample: 0 differing samples
  for pulse 1, pulse 2, triangle, noise, DMC, the VRC6 and the FDS. This
  is the check that can catch a wrong stem: the sum check cannot,
  because the interaction track is the remainder by definition.
- max |mix - sum of seven|: 4.2e-7 (all-channel NROM, peak 0.47),
  3.6e-7 (VRC6, 650 frames), 8.3e-7 (N163); bounded at 1e-5. The
  interaction term itself peaks at 0.135 against a 0.472 mix, so a
  six-stem mixdown would be audibly louder than the console.

The FDS test uses a synthetic 8 KiB BIOS written here (sound I/O on, a
sawtooth wavetable, direct gain, a pitch); Nintendo's BIOS is not used.

NSF ripping
-----------
Tracer steps the console one instruction at a time with a shadow call
stack: a JSR pushes its target; an interrupt pushes its vector (the CPU
services an interrupt as a step of its own, so it is recognised by
exactly three bytes pushed and the PC on the NMI or IRQ/BRK vector's
target, and the opcode at the pre-step PC, which was NOT executed, is
not taken for a call); an entry pops when S climbs back to its entry
value. The bus access log attributes every write in $4000-$4013, $4015,
$4017 (not $4014 OAM DMA, not $4016 strobe) to its call chain. propose()
picks play = the innermost routine enclosing every APU write of a frame
in the most frames, and init = the innermost routine enclosing the
writes made outside play (or none, with an RTS byte as a no-op init).
build_nsf() writes NSF v1 (NESM header per nesdev "NSF"), PRG loaded at
$8000, 16 KiB mirrored, mappers 0 and 3 only; a bank-switched board or a
driver in RAM is refused with the reason (the bbbradsmith ROMs run from
RAM and are refused). The CLI with --out plays the written NSF back,
traces it the same way and reports it verified only when its play calls
make the game's APU writes call for call.

Gate: the homebrew example ROM (example_music_rom, written for this; an
NMI -> BODY -> PLAY -> TRI driver plus $4014/$4016 writes and a main loop
that is JSR IDLE / JMP LOOP, the IRQ vector naming that loop): play
$C059 and init $C045 found; the ripped NSF's play is proposed again from
the player's own run and its first 298 play calls make the game's 231
APU writes exactly. This gate is what found the NSF player defect fixed
in the previous commit.

Mutants (each applied, test run, file restored by copy):
stems: no pre-cycle replay CAUGHT; triangle in the noise slot CAUGHT;
PAL pipeline at the NTSC rate CAUGHT; filter model ignored CAUGHT;
interaction forgets the expansion CAUGHT; gap check off CAUGHT.
ripper: outermost play CAUGHT; $4016 counted CAUGHT; 16 KiB not mirrored
CAUGHT; init outermost CAUGHT (first NOT CAUGHT: the example's init is
one level deep; remediated with a synthetic two-level test); push-count
check removed CAUGHT (first NOT CAUGHT; remediated by asserting no IRQ
and 115..=119 NMIs); pop off by one CAUGHT; JSR taken on an interrupt
step CAUGHT (first NOT CAUGHT because the main loop had no JSR;
remediated by adding JSR IDLE to it); NMI recorded as IRQ CAUGHT. The
NMI-count assertion also exposed a real defect in the first detector
(an opcode-based "was it a jump" test misread every NMI that arrived at
the main loop's JMP), fixed before commit.

Verification (worktree, this tree):
- cargo fmt --all --check clean; markdownlint on every changed file.
- clippy -D warnings: --workspace --all-targets; frontend scripting,
  scripting+hd-pack, retroachievements, full; core debug-hooks,hd-pack;
  wasm32 default and --no-default-features wasm-canvas. All clean.
- RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps clean
  (the pre-existing rustynes bin/lib filename-collision warning only).
- thumbv7em-none-eabihf no_std core build clean.
- cargo test -p rustynes-core --features debug-hooks,hd-pack 314 passed
  (stems:: 16, nsf_rip:: 8); -p rustynes-core 276; -p rustynes-mappers
  1,131; -p rustynes-frontend --lib 781 passed 1 ignored (stems 7 incl.
  the CLI parse test); --bins; -p rustynes-test-harness --features
  test-roms --test audio_expansion 25 passed.
- ffprobe could not read the scratch path (sandbox); sox/soxi read the
  WAVs as 32-bit float PCM, 44,100 Hz mono, 476,216 samples.

Not done: a desktop menu entry (both tools are headless passes over a
fresh console; from the window they need their own thread and progress
UI); per-chip stems for a multi-chip NSF (the trace holds only the
mapper's summed mix_audio; splitting needs a per-chip record in the
mapper's per-cycle path) and a chip's own voices; ripping bank-switched
boards (needs the NSF bank layout inferred). Recorded in the plan and
the CHANGELOG.

Provenance: written from nesdev "APU Mixer", "NSF", "PPU power up state"
and "FDS audio" and RustyNES's own mixer, BlipBuf and NSF player. No
reference emulator source was consulted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
…G-DAP)

`tests/roms/dap/dapdemo.c` is the smallest C program a source-level
debugger needs one of everything in: a global (`counter`), a function
with a parameter and a local (`add3(v)` / `r`), and a caller with a local
of its own (`main`'s `local`), in an endless loop so a breakpoint is hit
again with new values (`add3(5)`, then `add3(8)`). `BP:<marker>` comments
name the lines the tests break on, so the tests look line numbers up
instead of hard-coding them and an edit to the file cannot silently move a
breakpoint. Written for RustyNES and dedicated to the public domain
(CC0-1.0), to fit the committed corpus's permissive-only rule.

`build.py` builds it twice: cc65 (`cl65 -t nes -g -Wl --dbgfile`) gives
`dapdemo_cc65.nes` + `.dbg`, and the llvm-mos SDK (`mos-nes-nrom-clang
-O0 -g`) gives `dapdemo_llvm_mos.nes` + `.elf` (DWARF 5). It works in a
temporary directory holding only a copy of the source, so the debug
information records the relative name `dapdemo.c`, llvm-mos also gets
`-fdebug-prefix-map` (no build path in the ELF; checked, 0 matches), and
the tree never collects compiler intermediates. A missing tool is an
error, never a skip.

The outputs are committed so the `rustynes-dap` tests run on every CI leg
without either toolchain; the next commit adds a CI job that rebuilds
them with pinned toolchains and runs the same tests on that build.
Measured: two builds on this machine give byte-identical ROMs and ELF;
the `.dbg` differs (cl65's temporary file name and source mtimes), and it
records the absolute paths cc65's library was built from, which
`LICENSES.md` now says.

Toolchains used: cc65 at commit 034f73a9e1 (V2.19-3742; V2.19 itself
predates the `sp` -> `c_sp` rename of 2025-06-04, checked in the cc65
history), and the llvm-mos SDK v23.3.0, installed outside the repository
from its GitHub release (archive SHA-256 9f694497...56658c8, matching the
release's published digest).

Licensing: the ROMs link the toolchains' runtime libraries, cc65's
`nes.lib` (zlib) and the llvm-mos SDK's NROM startup and runtime
(Apache-2.0 WITH LLVM-exception); `LICENSES.md` gets a `dap/` section and
`README.md` a row. The committed `.nes` total there said 338 while
`git ls-files` counted 339; it now says 341 and records the recount.

Verification: `ruff check` / `ruff format --check` clean; pre-commit
(markdownlint, yaml, toml, whitespace) clean on these files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
…lone

Fixes a regression this branch introduced in 0ad500c (Core Options v2):
`libretro_info_capability_flags_match_the_core` derives each `.info`
capability flag from the environment call that implements it, searching
the core's source for `set_variables(` / `set_core_options_v2(` to decide
`core_options`. It read only `crates/rustynes-libretro/src/lib.rs`, and
0ad500c moved both calls into the new `options.rs`, so from that commit
the audit concluded the core declares no options and failed on
`core_options = "true"`. The per-crate test runs of 0ad500c..113b129
(`cargo test -p rustynes-libretro`) did not include the harness; the
end-of-branch gate run is what found it. Commits 0ad500c through
a9fa8f4 therefore fail this one harness test; this commit is the fix,
kept separate rather than folded back, so the history says so.

`core_source` now concatenates every `.rs` file in the crate's `src/`
except `abi_tests.rs` (the fake frontend, whose handlers name the same
commands and would make every flag read as implemented), sorted, and
asserts `lib.rs` is among them.

Verification: `cargo test -p rustynes-test-harness`: 164 passed, 0
failed, 1 ignored (the whole harness, run as part of the branch gates).
Mutant: the reader narrowed back to `lib.rs` -> CAUGHT by
`libretro_info_capability_flags_match_the_core`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
A new crate and binary, `rustynes-dap` (ADR 0049, `docs/dap-debugger.md`):
an editor launches it as its Debug Adapter Protocol adapter on stdio and
debugs cc65 or llvm-mos homebrew in C. With `--gdb 127.0.0.1:<port>` it
is instead a GDB Remote Serial Protocol stub for one client. Implemented
from the DAP specification and the GDB manual's remote-protocol and
target-description appendices; no emulator source was consulted.

Mechanism
- `debuginfo` lowers both formats into one model: an address -> line
  table, statement starts (where line breakpoints go and line steps
  stop), functions with ranges, entries, frame bases and variables, and
  globals. cc65: a new reader of `ld65 --dbgfile` (file / mod / seg /
  span / line / scope / sym / csym); C lines only when there are any, so
  steps move by C statement; narrower spans win where lines overlap;
  library modules are not the program. llvm-mos: `gimli` 0.34 (line
  program, DIEs) over sections `object` 0.40 finds, with the location
  operations a 6502 compiler emits decoded by hand, because llvm-mos's
  register numbers (RCn = 0x20000+n, RSn = 0x30000+n, from llvm-mos's
  published DWARF Specification for MOS-Compatible Processors) do not fit
  gimli's 16-bit `Register`. `__rcN` ELF symbols give each RCn's
  zero-page address.
- `engine` drives one `Nes` with the core's own exec breakpoints (no new
  PC check): the armed list is the union of user breakpoints, per-function
  frame hooks and one temporary breakpoint for step-over / step-out. The
  core stops before the PC and skips it once, so a hook costs a return
  from `run_frame` and nothing else. A shadow call stack records
  `(function, S)` at each entry and samples the frame base at the frame
  hook (llvm-mos: `prologue_end`; cc65: after a prologue `JSR` to
  `pusha`/`pushax`/..., because cc65's `csym` offsets are relative to the
  soft-stack pointer after the register-passed parameter is pushed,
  established black-box on the fixture: only that point reads back
  `v == 5` and `r == 8`). Frames whose entry `S` is below the current `S`
  have returned; caller positions come from the return words the JSRs
  pushed. `next` steps over JSRs with a temporary breakpoint guarded by
  `S`, so recursion does not stop it early; `stepIn` into a function with
  source finishes its prologue; JSRs into code without source are always
  stepped over; a line step gives up after 5M instructions rather than
  hang.
- `dap`: initialize, launch (program / debugInfo / sourceRoot /
  stopOnEntry; debugInfo defaults to `<program>.elf`, then `<stem>.dbg`),
  setBreakpoints (moves to the next line with code), function and
  instruction breakpoints, configurationDone, threads, stackTrace, scopes
  (Locals / Globals / Registers), variables, evaluate, readMemory,
  continue / next / stepIn / stepOut (instruction granularity too) /
  pause, disconnect. While running, the run loop polls the request
  channel per frame and per hook, so pause and setBreakpoints act within
  a frame; stopped-only requests are refused while running.
- `gdb`: qSupported, QStartNoAckMode, qXfer target.xml, ?, g, p, m, Z0/Z1,
  z0/z1, c, s, vCont, the 0x03 interrupt, k, D. The description numbers
  a x y p s pc as 0-5 (llvm-mos's DWARF order) and, with an ELF, adds
  rcN / rsN with LLDB's `dwarf_regnum`.

Security: DAP opens no socket; the GDB stub binds loopback only (a
non-loopback address is refused at startup, no override) and serves one
client; neither protocol can write memory or registers (no setVariable /
writeMemory; G/P/M/X answer E01). Reads are side-effect free (`$2002`
does not clear VBlank). ROM, debug file, DAP message and GDB packet sizes
are bounded. Diagnostics use a non-panicking stderr macro: the first GDB
test run found that a closed stderr pipe made `eprintln!` kill the stub.

Gate (cargo test -p rustynes-dap --locked: 21 unit + 3 + 6 + 3
integration tests, 0 failed)
- `dap_e2e`: the real binary over stdio with a scripted DAP client, per
  toolchain: stop at a source breakpoint in add3, stack add3 <- main on
  main's call line, read v == 5 and main's local == 5, `next` to the
  return line, r == 8 (variable, readMemory and evaluate), continue to
  the second call (v == 8, global counter == 8), pause while running,
  disconnect and a clean exit; plus refusals (bad ROM, bad debug file,
  writeMemory, requests before launch).
- `engine_fixtures`: the same through the API plus stepIn / stepOut, a
  breakpoint that moves forward, the caller PC holding a JSR, and the
  no-perturbation check: after two stops and a read of all 64 KiB
  (registers included), the debugged machine's snapshot equals the bare
  core's with one exec breakpoint, byte for byte.
- `gdb_e2e`: the stub over TCP: target description, Z0, `c` stops at the
  line's address, v read as rs0 + its fbreg offset, `s`, write refusals,
  interrupt -> T02, the cc65 global before and after its first store,
  and the non-loopback refusal.
- CI job `dap-fixtures` (added to `CI success`'s needs): builds cc65 at
  commit 034f73a9e1, installs the llvm-mos SDK v23.3.0 with its archive
  SHA-256 checked, rebuilds the fixtures with `build.py`, and runs the
  crate's tests on that build (`RUSTYNES_DAP_FIXTURES`). Simulated
  locally: the rebuilt fixtures pass all tests; the rebuilt ROMs and ELF
  are byte-identical to the committed ones.

Mutants (14, driver and logs in the gitignored
salvaged/evidence/v3.6.0-dap/): 11 caught on the first sweep. Three NOT
CAUGHT, each a test gap, now closed and re-run CAUGHT: caller PC taken
as the pushed word instead of the JSR (same source line, so line checks
could not see it; now the caller PC must hold opcode $20); debugger
reads through the side-effecting bus probe (no register was read; the
no-perturbation check now reads all 64 KiB); cc65 overlapping C lines
with the wider winning (no overlap in the fixtures; a unit test with an
enclosing C line now pins it).

Other gates run: cargo fmt --all --check; cargo clippy --workspace
--all-targets --locked -D warnings (clean); RUSTDOCFLAGS=-D warnings
cargo doc --workspace --no-deps (clean); cargo deny check licenses bans
(ok); cargo test -p rustynes-test-harness --locked (164 passed, 0
failed, 1 ignored: provenance and doc audits included); markdownlint via
pre-commit on every changed Markdown file. The frontend's feature
clippies and the wasm32 clippies were not re-run: this change does not
touch `rustynes-frontend` and the lock change is additions only.

Trap recorded in docs/agents/dependencies.md: letting cargo write the
lock for the new dependencies also moved seven unrelated Windows edges
(windows-sys 0.52/0.59 -> 0.61.2, one getrandom 0.3 -> 0.4), even via
`cargo update -w` from the HEAD lock. The committed lock is HEAD plus
the three new package blocks (29 added lines, 0 removed), accepted by
`cargo metadata --locked --offline`.

Provenance: `debuginfo/cc65.rs` carries an origin disclosure, not a
Provenance header: it was written from ld65's output and cc65's docs,
but its author had read `rustynes-frontend/src/debugger/source_map.rs`
(Mesen2-derived) earlier in the session. Flagged for maintainer review;
not self-certified.

Not done: no VS Code extension ships (a package.json sketch in the spec,
untried in VS Code); no editor other than the scripted client and no
GDB / LLDB has driven it (none has a MOS target); no writes, by design;
cc65 types (cc65 records none: one byte shown, the type says so); DWARF
location lists, composite locations, lexical-block scoping; disassemble,
data breakpoints, conditional and hit-count breakpoints; FDS / NSF.
AGENTS.md's crate list does not name the crate yet (left to the
maintainer).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
T-V350-REVIEW-RESIDUE: the Antigravity suggestions on #624 not taken at
v3.5.0, each confirmed with a failing test or refuted. Three are here.

1. A key held across a rebind stayed held for good (confirmed).
   `InputState::reload_bindings` replaced the key map and kept every
   held flag. A key down while its binding moved (Power Pad mat button
   4 on F, rebound to Y, F still held) set its bit under the old map;
   its release, looked up in the new map, no longer named that position,
   so the bit stayed set until the app restarted. The same held for the
   controller keys, both Hyper Shots and the microphone, and in the app
   for the Family BASIC matrix, which `App` keeps itself.
   Fix: `reload_bindings` releases every keyboard-held input
   (`keyboard_buttons`, `power_pad`, both Hyper Shot masks,
   `microphone`) before swapping the map, and the app clears
   `family_keyboard` on the same dirty-flag path. Gamepad state is left
   alone: its map is separate and not part of a key rebind. The next key
   event re-establishes what is really down, so the only cost is that a
   key held through a rebind must be pressed again.
   Test: `a_rebind_releases_what_the_old_binding_held` (mat button and
   microphone held, both rebound, both released under the old keys).
   Mutation: removing the five releases makes it FAIL; restored from a
   snapshot.

2. Every user-named file was read whole with no bound (confirmed).
   ROM, IPS/BPS/UPS patch, FDS BIOS, .rnm movie, foreign movie, TAS
   project, symbol file, source map, Lua script, palette, splits file,
   per-game sidecar, and the CLI's `verify` and `speedrun` inputs all
   went through bare `std::fs::read` / `read_to_string`. Two kinds of
   path break that: a FIFO blocks in `open` until a writer appears (the
   window thread with it), and a character device such as `/dev/zero`,
   named `game.nes` or reached through a symlink, streams for ever until
   allocation fails. `metadata().len()` is 0 for both, so a length check
   alone passes them, which is why the bundle loader's v3.5.0
   `read_bounded` bounds the READ.
   Fix: a shared `bounded_read` module. `read_limited` refuses a
   non-regular file from `fs::metadata` (which follows symlinks, so the
   target is judged) before opening anything, then reads at most
   limit+1 bytes and refuses if the extra byte arrived; `read` /
   `read_to_string` apply 64 MiB, far above any NES image, movie, script
   or palette. 22 call sites now use it; the bundle's plain-ROM branch
   too. Deliberately NOT changed: files the app writes itself in its data
   directory (the FDS disk save, the RA progress sidecar, cheats, save
   states, config), which no user path names.
   Tests: a regular file, the limit's edge (16 refused at 17 bytes, 17
   accepted), a directory, `/dev/zero` and a symlink to it, and a FIFO
   made with `mkfifo` (refused without blocking; the test would hang
   if the metadata check were gone). Mutation: disabling the regular-file
   check makes the device test FAIL (run alone, since the FIFO test
   would block); restored from a snapshot.

3. `lua_eval` from the MCP server during a real-time run (refuted).
   Each runner frame takes the session mutex (`host.rs`, the
   mcp-realtime thread) and every tool call takes the same one, so an
   eval sees a single frame boundary and the runner waits; the breach
   check compares two snapshots taken under that lock, so a running
   machine cannot read as a breach; the runner's "fell behind: run on
   rather than burst" path absorbs the stall. Pinned rather than argued:
   `real_time_running_pauses_and_stops_at_breakpoints` now evaluates Lua
   mid-run and asserts `ok` with `state_unchanged`, and its existing
   `movie_verify` afterwards proves the run's journal still replays.

The two sibling items (the dead `x_hit_q`, the ungated `nt_ovr`/IRQ)
are on the sibling's own v3.5.1 branch.

Verified: rustynes-frontend lib 780 passed, 0 failed, 1 ignored (the 18
new/affected tests by name); rustynes-mcp all test targets pass; cargo
fmt --check; clippy -D warnings for --workspace --all-targets and
rustynes-frontend with scripting, scripting+hd-pack, retroachievements
and full, plus both wasm32 invocations (run through a Python driver:
an earlier zsh loop passed each argument list as ONE word, so its
"clean" results had run nothing); RUSTDOCFLAGS=-D warnings cargo doc
--workspace.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
The v3.5.1 plan (to-dos/plans/v3.5.1-plan.md), written as the work was
done rather than ahead of it, and the records the two fix commits
(fda79e5, the MMC5 overrides; 6b2ef14, the rebind and file-read
residue) leave behind.

- The plan states the branch point and its consequence: the maintainer
  asked for v3.5.1 to be committed and not pushed, so v3.6.0 is built on
  this branch, which must therefore merge first, and a review change here
  means rebasing v3.6.0.
- What the MMC5 fix moves, measured: the commercial and epoch suites
  pass with no snapshot or fingerprint moved, and a temporary probe found
  0 accesses in 3,600 frames of each of the three local MMC5 games where
  the old latch would have chosen another bank. EMULATION_EPOCH stays 6;
  the residual risk (a Koei screen reading CHR through $2007 after an
  extended-attribute frame, which the measured play never reaches) is
  written down rather than left implicit.
- One verdict per T-V350-REVIEW-RESIDUE suggestion: two confirmed and
  fixed, one refuted and pinned by a test, the two sibling items with
  the sibling's own branch.
- ROADMAP: T-MMC5-LATCH-VBLANK's oracle half, T-WIN-SCRIPT-IPC-CI (its
  closing run, CI 40042068313, named) and T-V350-REVIEW-RESIDUE closed.
- CHANGELOG [Unreleased] gains a Fixed section with the three fixes in
  user terms.

Verified: markdownlint on the three files; release_anchor_audit 18/0,
release_notes_render_audit 14/0, release_state_prose_audit 9/0 and
contribution_checklist_audit 4/0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
T-ANDROID-API37 (v3.6.0 plan row 5) and T-ANDROID-AUDIO-NONBLOCKING
(audit AND-R2-01), together because the second changes the audio path the
first constrains.

targetSdk 36 -> 37 (Android 17; compileSdk was already 37, the
android-37.0 platform's source.properties reads "Android SDK Platform 17").
The API 37 behaviour list was read against the app (developer.android.com,
"Behavior changes: apps targeting Android 17"): no reflective or JNI write to
a static final, no MODE_BACKGROUND_ACTIVITY_START_ALLOWED, no
setContentCaptureEnabled, no RFCOMM, no System.load(); the widget is
text-only. Two items need code.

1. Local network permission. Android 17 blocks traffic to and from local
   addresses for an app targeting 37 (UDP sends fail EPERM, TCP times out)
   unless it holds ACCESS_LOCAL_NETWORK (NEARBY_DEVICES group). LAN /
   direct-IP netplay is exactly that traffic. LocalNetwork.kt holds the
   decision as plain JVM code: below API 37, or for a target below 37,
   PROCEED without asking (the permission does not exist on older devices,
   and asking for an unknown permission is answered "denied", which would
   refuse LAN play everywhere; Google also says not to request it before
   targeting 37); otherwise PROCEED if granted, else ASK. After the prompt,
   a grant runs the held action and a refusal does not start LAN play.
   MainActivity's withLocalNetwork() wraps the Host and Join callbacks with
   a RequestPermission launcher. The refusal is shown in the netplay sheet
   (new `notice` parameter): `status` is drawn only on the idle screen, so
   the first emulator run showed nothing at all while a game ran. The
   manifest declares the permission.
   Room-code play is NOT gated: rustynes-netplay's NatConnect exchanges only
   the STUN-reflexive public address or a TURN relay address
   (`SignalMessage::PublicAddr`, nat_connect.rs tick_exchanging / the relay
   hand-off), never a host candidate, so two phones on one Wi-Fi meet through
   their router's public address. A user-configured relay on a LAN address
   would be local traffic; not handled, stated in docs/android.md.

2. Background audio. On Android 17 playback and volume calls are silenced,
   and focus requests fail, for an app with no visible activity and no
   qualifying foreground service, "silently". RustyNES plays only while it
   is started (on screen or PiP; PiP keeps the activity started). v2.7.4
   (AND-03) already paused emulation in onStop. The audio side now has its
   own rule: EmulatorHandle.visible (set in onStart/onStop) and
   audioMayPlay(visible, running) gate the loop, so nothing that unpauses
   while the activity is stopped can start the track or request focus. That
   mattered: a focus request refused in the background set focusMuted and
   nothing asked again after the app came back. No foreground service.

3. Non-blocking audio (AND-R2-01). The blocking WRITE_BLOCKING write WAS the
   pacing, which is why the maintainer kept it in v3.2.0 (c04da0c): the
   draft used the 8/16-bit byte[] overload on a PCM_FLOAT track, dropped
   partial writes and put no clock in place. AudioPacing.kt does it in that
   order of concerns:
   - AudioPlayer.writeBytes writes `write(ByteBuffer, size,
     WRITE_NON_BLOCKING)`, the overload that takes the track's own format;
   - AudioQueue keeps what the track did not take and offers it first next
     time, bounded at one track buffer, dropping the OLDEST whole frames on
     overflow (only when the track has stopped taking audio);
   - AudioPacer.delayNanos: sleep (excess over target) / rate, where target
     is two NES frames of audio when the buffer allows it (at least one, at
     most capacity minus one frame); zero below the target (refill now);
     capped at two frames; the wall clock when the frame wrote no audio (as
     before for silent games) or when the head has not moved for six frames
     with audio queued. The queued count is written frames minus
     playbackHeadPosition modulo 2^32 (the platform's unsigned wrap).
   The loop's pacing now calls audio.paceDelayNanos(elapsed) and sleeps with
   a cancellable `delay`, off the shared Dispatchers.Default worker the block
   used to occupy. The unused blocking write(List<Float>) is removed.
   A trap caught in review before commit: the first draft detected a stall by
   counting calls with an unmoving head; a HAL moves the head once per mixer
   period, so a loop refilling a low buffer calls several times between
   updates, and the third call slept a whole frame while the buffer ran dry.
   The stall is now a time window, and below the target the pacer returns 0
   before looking at it (test a_bursty_head_below_the_target_never_sleeps).

Tests (JVM, both flavours): LocalNetworkAccessTest (7) pins decide /
afterPrompt over every SDK and target below 37, the permission name, the
manifest declaration and targetSdk read from the sources, and the Compose
wiring by source check. AudioPacingTest (21) pins the target, floor, cap,
wall clock, stall window, bursty head, 32-bit wrap, the queue's order,
partial writes, overflow and errors, audioMayPlay, the MainActivity wiring
(no AudioTrack.WRITE_BLOCKING, the queue, the pacer, the visibility gate),
and runs the loop 30 s against a simulated 48 kHz DAC whose visible head
moves in 20 ms steps, with delay() truncated to milliseconds and up to 2 ms
of scheduler jitter: no underrun after a 2 s warm-up, nothing dropped.
build.gradle.kts declares the manifest, itself and src/main/java as test
inputs: the mutation pass found a manifest-only change left the test task
UP-TO-DATE, so removing the permission passed on a stale result.

Mutation pass (one mutant per gradle run, the file restored after each):
23 of 23 caught in the final pass, each by a named test (decide x3,
afterPrompt, manifest, targetSdk, the host gate, the sheet notice, pacer
floor / cap / stall / stall-before-floor / stall reset / wrap / target /
wall clock, queue order / partial / overflow, audioMayPlay, the loop gate,
onStop, a blocking write restored). It took three remediations:
- manifest-no-permission and target-36 were NOT CAUGHT at first, and not
  because the test was weak: Gradle left the test task UP-TO-DATE, since
  the test reads sources that were not task inputs. Fixed by the inputs
  declaration above; both then caught.
- queue-skip-pending (fresh audio written while older bytes still wait)
  NOT CAUGHT: every test drained the pending bytes in one call. Added
  fresh_audio_never_overtakes_pending_audio.
- stall-checked-before-floor (the first draft's bug, reinstated) NOT
  CAUGHT: the bursty test spanned 19 ms, inside the stall window. It now
  spans 200 ms.

Verification run (this worktree, NDK 29.0.14206865, JBR 21, Gradle 9.8.1):
- ./gradlew :app:testFossDebugUnitTest :app:testPlayDebugUnitTest: 56 tests
  each (28 before), 0 failed; :app:assembleFossDebug, assemblePlayDebug,
  assembleFossRelease, assemblePlayRelease (R8): success, no Kotlin
  warning (the AGP buildconfig deprecation line is pre-existing);
- cargo ndk -t arm64-v8a -t x86_64 --platform 26 build --profile
  release-mobile -p rustynes-mobile -p rustynes-android: success;
- cargo test -p rustynes-mobile: 69 passed; cargo test -p rustynes-android:
  builds (no tests); cargo clippy -p rustynes-mobile -p rustynes-android
  -p rustynes-ios --all-targets -D warnings: clean;
- cargo fmt --all --check, cargo clippy --workspace --all-targets -D
  warnings, RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps:
  clean; markdownlint (pre-commit) on the changed Markdown: passed.
No Rust source changed in this commit.

Emulator, 2026-10-23 (run-sheet L1, L2; not a device): two headless
android-37.0 google_apis x86_64 r8 AVDs, emulator 37.2.12, fossDebug APK,
AccuracyCoin autoloaded, driven by uiautomator. Fresh install read
targetSdk=37, ACCESS_LOCAL_NETWORK granted=false. Host raised the
"Nearby devices" prompt; Don't allow: notice shown, no UDP 7100 socket in
/proc/net/udp; second ask prompted again; second refusal -> USER_FIXED, the
third host showed the notice without a prompt. After `pm grant` (the
Settings path) host bound 10.0.2.15:7100; the host's UDP 7100 forwarded
(`emu redir add udp:7100:7100`) and the second emulator joined
10.0.2.2:7100 (a private address): its prompt, Allow, then both In game,
host frame 270 -> 2490 and joiner 154 -> 2387 over about 40 s. Emulator
traps, not the app's: surfaceflinger aborted in luma sampling
(hasReadColorBufferDma) until `setprop debug.sf.luma_sampling 0`, so no
screenshots; other jobs held the host at load 35-56 and gave system ANR
dialogs. The audio reading in row L4 (6.6% underrun frames over 10 s at
128% CPU on a software GPU) is not a measurement of the pacing.

Not done: every device row (L1-L5; a physical device was attached over
wireless adb and deliberately left alone); the drift check L4 that the
maintainer asked for before the blocking write went; L3 (room-code with the
permission refused, needs a relay). Recorded in the v3.6.0 plan.

Docs: docs/android.md (targetSdk line, which still read 35; the foss
permissions line; workstream C; an "Android 17" section),
docs/mobile-v2.9.3-run-sheet.md rows L1-L5, ROADMAP rows, the plan's row 5
record, CHANGELOG [Unreleased].

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
T-IOS-DURABLE-WRITE and T-IOS-FDS-PERIODIC, both from #605's review, in one
change because the periodic disk write is the newest user of the durable
writer.

THIS SWIFT HAS NOT BEEN COMPILED. It was written on Linux with no Swift
toolchain. The iOS workflow's Simulator build is its first compile (on the
first push), and the device checks are run-sheet rows T13 and T14. What is
tested here is a source audit of the wiring, not the behaviour.

1. DurableWrite.swift: the Swift counterpart of the desktop's
   crates/rustynes-frontend/src/atomic_write.rs. Foundation's
   `Data.write(to:options: .atomic)` renames a scratch file into place but
   never syncs the file or its directory, so a power loss between the write
   and the medium can leave the new entry naming bytes that never landed (an
   empty or truncated save). On Apple platforms fsync(2) itself does not
   push through the drive's write cache; fcntl(F_FULLFSYNC) does (Apple's
   fsync(2) man page). DurableWrite.write(_:to:):
   - creates the parent directory;
   - opens a SIBLING scratch file `.<name>.<pid>.<counter>.tmp` with
     O_WRONLY|O_CREAT|O_EXCL|O_NOFOLLOW|O_CLOEXEC, 0644 (the pid and a
     locked per-process counter are what make O_EXCL adoptable: a fixed
     name would fail every save after one crash);
   - writes every byte (short writes continued, EINTR retried);
   - F_FULLFSYNC, falling back to fsync where the filesystem refuses it;
   - close (checked), rename(2) over the target, then fsync of the
     directory (O_RDONLY|O_DIRECTORY), where EINVAL/ENOTSUP mean the
     filesystem offers no barrier (proceed, the desktop's rule) and any
     other error propagates;
   - on any failure before the rename, the scratch file is unlinked and the
     error thrown, so the target is untouched and the callers' retry logic
     (the battery saver's baseline, the FDS retry set) sees a failure.
   Not carried over from the desktop, with reasons in the file: the
   target's mode and symlink resolution (a sandbox has neither use). The
   file-protection class is unchanged (`.atomic` set none either).

   Every Swift file write now goes through it, 18 sites: the six the ticket
   names (FDS saves in AppModel, BatterySave, GameOverrides, MovieManager,
   HDPackStore, PaletteManager) and the rest (FdsBiosStore, SaveStateManager
   x6, ROMLibrary import and index, the RA progress sidecar, the key
   migration's copy, CrashReporter's report, which was
   `String.write(to:atomically:encoding:)`). The two `atomically: true` left
   in the tree are CloudKit's CKModifyRecordsOperation, not file writes.

2. FdsDiskSaver.swift + AppModel.startFdsPoll: until now iOS wrote an FDS
   disk only on backgrounding, a game switch and close, so a crash, a jetsam
   kill or a debugger stop while playing lost every in-game save since; the
   1 s timer existed only for battery cartridges. A repeating 1 s Timer,
   started in openGame once `emulator` and `currentEntry` both name the new
   game (no await separates them from the start, so no tick can see a mixed
   pair) and invalidated in closeGame, calls flushFdsDiskIfDirty(wait:
   false). FdsDiskSaver keeps Android's ordering: dirty flag cleared BEFORE
   the image is copied (a disk write during the copy sets it again); the
   write runs on a serial queue, off the main actor (a disk side is about
   65 KiB and F_FULLFSYNC can take milliseconds); a failure marks the disk
   for retry under a lock, so the next tick writes it though the flag is
   clear; the session-ending paths (wait: true) first drain the queue, so a
   queued write's outcome is known before the retry check. AppModel's
   `fdsRetry` set is gone; flushBatteryInBackground asks
   `fdsSaver.hasRetries`.

Gate: crates/rustynes-test-harness/tests/ios_source_audit.rs, 4 tests:
- no Swift file but DurableWrite.swift contains `.write(to:`,
  `.write(toFile:`, `options: .atomic`, `atomically: true, encoding` or
  `createFile(atPath:` in code (comments stripped), over all 50 files under ios/RustyNES;
- DurableWrite.write keeps its order (scratch name, O_EXCL create, write,
  F_FULLFSYNC, fsync fallback, rename, directory sync), unlinks on failure,
  retries EINTR, and the directory sync propagates real errors;
- the six named sites call DurableWrite.write;
- the poll: a repeating 1 s timer flushing with wait: false, invalidating
  any earlier timer, started after `currentEntry = entry`, stopped in
  closeGame; the saver drains before checking, clears before copying,
  never waits on the tick, records a failure for retry.
Line endings are normalised (the CRLF trap from v3.4.1 and #624).

Mutation pass (one Swift edit per run, the file restored after each): 20 of
20 caught, each by the test it targets: five sites back to Foundation writes
(battery, movie, crash report via atomically:, a save state with a plain
write, the FDS saver), F_FULLFSYNC removed, directory sync removed, O_EXCL
replaced by O_TRUNC, no unlink on failure, EINTR not retried, directory sync
swallowing errors, a rename moved before the sync, the poll not started /
not stopped / at 5 s / waiting, clear-after-copy, no retry, the tick waiting
on the queue, no drain before the check. One first-draft mutant was
malformed (it inserted a different `rename("", "")` call the audit does not
look for) and was replaced by a real reordering, which is caught.

Verification run: cargo test -p rustynes-test-harness: 168 passed, 0 failed,
1 ignored over 108 binaries, ios_source_audit 4 of them; cargo check -p
rustynes-ios --all-targets (host) and clippy -D warnings on it: clean;
cargo fmt --all --check, workspace clippy -D warnings, RUSTDOCFLAGS="-D
warnings" cargo doc: clean; markdownlint on the changed Markdown: passed.

Docs: docs/ios.md (battery saves; a v3.6.0 section), run-sheet rows T13
(durable writes across a hard power-off) and T14 (an FDS save surviving a
kill in the foreground), a pointer in docs/ios-v1.9.9-readiness.md, the
ROADMAP rows, the plan's row 5 record, CHANGELOG [Unreleased].

Not done: compiling and running any of it (no Swift toolchain or device
here; the iOS workflow and rows T13-T14 are what unblock it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
The version bump, the anchors and the release notes for v3.5.1, a patch
release on v3.5.0 "Chorus": the MMC5's split-screen and
extended-attribute CHR banks apply only while the PPU renders (in both
cores; the sibling's half is on its own feat/v3.5.1 branch), a key held
while it is rebound is released, user-named files are read with a bound
and devices and pipes refused, and the rest of the v3.5.0 review's
residue is settled.

The codename "Reprise" is provisional: the maintainer chooses codenames
at the cut and was not asked this time (the request was to work through
without stopping). It was checked unused in CHANGELOG.md and
CHANGELOG-FULL.md. Renaming it is one CHANGELOG header, the notes title,
the VERSION-PLAN row and the anchors bump_release.py wrote.

bump_release.py --apply moved every anchor it owns (Cargo.toml and both
lockfiles, the cosim crate, the libretro .info display_version, Android's
versionCode, the iOS project, the hosted Worker's package files and
RA_USER_AGENT, and the prose anchors in AGENTS, ARCHITECTURE, OVERVIEW,
README, ROADMAP, SECURITY, SUPPORT and STATUS), with the lead sentence
describing v3.5.1 and v3.5.0's demoted to "Built on". By hand:
- the CHANGELOG [3.5.1] section header (the entries were already under
  [Unreleased]; the heading is kept, empty, as the audit requires);
- to-dos/ROADMAP.md's release-line sentence: v3.5.0 loses "the current
  release", v3.5.1 gains it (the tool refuses without a written entry);
- VERSION-PLAN.md: the forward-table row, and the release table's
  "(current)" marker moved from v3.5.0 to a new v3.5.1 row
  (release_anchor_audit failed until it moved, which is its job);
- .github/release-notes/v3.5.1.md, in v3.4.2's shape: what was fixed in
  user terms, what the MMC5 fix changes in recorded play (measured:
  nothing moved, a probe found no affected access, epoch stays 6, the
  residual risk named), what the review residue settled, the MiSTer
  section (bitstreams compiled from the sibling's release commit at
  release time, not now: a compile today would carry an earlier date
  than the release), verification and install.

Compatibility: emulation epoch 6, netplay protocol 9, .rnm format 7 and
every save-state section unchanged from v3.5.0; no format moves.

Verified on this tree: release_anchor_audit 18/0,
release_state_prose_audit 9/0, release_notes_render_audit 14/0,
contribution_checklist_audit 4/0, libretro_info_audit 6/0;
pre-release.sh reports the notes reflowed (911 words) and the CHANGELOG
shape right. The full workspace suite with test-roms and commercial-roms
is running on this tree; its counts go into docs/STATUS.md in the commit
after this one.

NOT pushed, by the maintainer's instruction (2026-10-23). v3.6.0 is
being built on this branch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
# Conflicts:
#	CHANGELOG.md
# Conflicts:
#	CHANGELOG.md
#	to-dos/plans/v3.6.0-plan.md
# Conflicts:
#	CHANGELOG.md
#	to-dos/plans/v3.6.0-plan.md
The NSF player maps a hand-assembled 6502 driver into the expansion
window and points the reset, NMI and IRQ vectors at it. It sat at
$5000-$504F, so its NMI entry (+$15) was $5015, the MMC5's status port.
For an NSF declaring MMC5 audio (header $7B bit 3), NsfExpansion's
cpu_read answers $5015 with the MMC5 status BEFORE the driver image is
consulted (nsf.rs, the expansion read precedes the DRIVER_BASE range),
so every NMI fetched the status byte as its first opcode instead of
`PHA`, and `play` never ran. The stems agent flagged it from the code
while fixing the 60 Hz driver; this reproduces and fixes it.

Pinned red first: an_mmc5_nsf_calls_play_every_frame, the stems
branch's counting 60 Hz NSF with $7B = $08, asserts play runs 114-118
times in 120 frames. Before the fix it ran 2 times.

Fix: DRIVER_BASE = $5100. Page $51 is clear of every expansion chip an
NSF can declare -- the FDS at $4040-$4092, the Namco 163's $4800 port,
and the MMC5's audio $5000-$5015, multiplier $5205-$5206 and ExRAM
$5C00-$5FF5 (the NSF form of the MMC5 uses those and none of its
banking or $5100-$5130 configuration registers) -- and below the
$5FF0-$5FFF timer and bank registers. Moving the whole page keeps every
low byte of the image, so no instruction's cycle count, branch offset or
page-crossing changes, and the driver's timing is identical. The three
jump high bytes that encoded $50 literally now use DRIVER_HI, derived
from DRIVER_BASE so the two cannot disagree again; the listing comments
and docs/mappers.md name the new addresses, and the unit test that
asserted the literal $50 byte asserts DRIVER_HI.

Verified: rustynes-mappers nsf tests 25/0, rustynes-core nsf tests 5/0
(the new one among them), fmt, clippy -D warnings on both crates. The
full gates run on the integrated v3.6.0 tree next.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
Integration notes after merging the four v3.6.0 oracle branches.

- AGENTS.md's "Where things live" names the new `rustynes-dap` crate
  (the DAP agent left that edit to the integrator, deliberately).
- ROADMAP mints T-CABINET-OVERCLOCK-DECAY-STATE for v3.7.0 with its
  cause written down. The libretro agent found a Vs. DualSystem state
  that does not round-trip at overclock x2: 64 bytes, the two consoles'
  32 OAM-decay ages each one higher ten frames after a restore. The
  snapshot stores each age as `dot_counter / 3 - timestamp` and rebuilds
  it against the live, unserialized `dot_counter`; the integer division
  drops the counter's phase mod 3, and the overclock's PPU-less CPU
  cycles put the save and the restore at different phases (x1, and a
  single console at x2, happen to line up). Fixing it means serializing
  the phase or keeping the decay clock in dots, a save-state or behaviour
  change inside ppu.rs's Mesen2-derived decay region, so it is recorded
  as a decision for a later release rather than patched into this one.
  Play is unaffected (the model is off by default); RetroArch netplay's
  state checksums are what would notice.
- The v3.6.0 plan gains a "For the maintainer" section: the provenance
  question about rustynes-dap's cc65 reader (written after its author
  had read a Mesen2-derived file; disclosed in the file, not certified
  either way), the libretro state's 16-byte growth and how to undo it,
  the NSF output change and the epoch question it raises for the cut,
  and what no real client or device has yet run.

Verified: markdownlint on the three files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
The integrated v3.6.0 tree failed one test the stems branch had not run:
`no_bare_fs_write` (rustynes-frontend's integration-test audit that every
production write of user data goes through `atomic_write` or an argued
ALLOWED entry) flagged `stems_cli.rs:115`, `File::create(path)`. The
branch's own gate ran `rustynes-frontend --lib`, which does not build
the crate's tests/ directory; the workspace run here did.

The finding is real. `WavWriter` created the user's output file in
place, wrote placeholder sizes, streamed samples, and patched the header
in `finish`. An export cut short (an error mid-run, ^C, a full disk)
left a truncated WAV whose header claims zero samples under the name the
user asked for, having already destroyed any previous export there.

`write_atomic` is the wrong tool: it takes the whole contents, and the
export streams eight tracks of up to ~690 MB an hour each. So the writer
does what `av_record`'s staged encode does: it streams into
`<name>.rustynes-partial` beside the target, and `finish` patches the
header, `sync_all`s, and renames over the target. `Drop` removes the
staged file if `finish` never ran. The audit's ALLOWED list gains the
argued entry (file, call, count 1, reason), like av_record's.

Test: a_wav_is_published_only_when_finished -- a previous export is
seeded, a writer streams and is dropped unfinished (the staged file
existed and is gone, the previous export is intact byte for byte), then
a second writer finishes (no staged file left, the target is the new
58 + 256-byte WAV). Mutation: removing the rename from `finish` makes it
FAIL; restored from a snapshot.

Verified: no_bare_fs_write 1/0; rustynes-frontend lib `stems` 8/0;
clippy -D warnings on rustynes-frontend --all-targets; cargo fmt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
The v3.5.1 cut kept EMULATION_EPOCH at 6 on a reading of the bump rule's
stated trigger: no golden, snapshot or epoch fingerprint moved under the
MMC5 fix (fda79e5), and a temporary probe found no affected CHR access
in 3,600 frames each of Bandit Kings of Ancient China, Uchuu Keibitai SDF
and Castlevania III. The maintainer decided otherwise (2026-10-23): the
fix changes what a $2007 CHR read in vertical blank returns, which is a
bus value the rule's first sentence covers ("a different framebuffer,
audio sample or bus cycle from the same inputs"), and a v3.5.0 movie of
a game that makes that read would replay differently with no warning.
Raising the epoch turns that silent divergence into a refusal that
names both epochs. It is the stricter reading of the rule and matches
the maintainer's standing preference for enduring correctness over
compatibility.

BREAKING CHANGE: movies recorded on v3.5.0 (epoch 6) and v3.5.0 netplay
peers are refused by v3.5.1 (epoch 7), with MovieError's / the
handshake's epoch-mismatch reason. Re-record movies under v3.5.1 and
keep both netplay peers on the same release. Save states do not carry
the epoch (it is recorded only in movies, the netplay handshake, the
BK2/legacy import paths and the memory-export header) and load as
before; netplay protocol 9 and .rnm format 7 are unchanged.

- hardware_options.rs: EMULATION_EPOCH = 7 and its table row, which says
  that no recorded output moved and why the epoch rose anyway.
- golden/epoch_fingerprint.tsv: `# epoch 7` and `# last_release_epoch 7`.
  docs/agents/ci-and-release.md requires the cut to set
  last_release_epoch to the shipped epoch; v3.5.0's cut left it at 5
  (one release behind its own epoch, 6), so this also corrects that slip.
  The panel's fingerprints are unchanged; only the header moves.
- CHANGELOG [3.5.1] gains a Breaking section; the release notes' first
  paragraph and their "what the MMC5 fix changes in recorded play"
  section, both VERSION-PLAN rows and the v3.5.1 plan now say epoch 7 and
  why, instead of "stays 6".

Verified, in a separate target directory (the full ROM suite holds this
tree's lock): rustynes-core lib 275/0, rustynes-netplay lib 149/0,
epoch_fingerprint (test-roms) 2/0; markdownlint on the four changed
documents. The full workspace ROM suite running on this branch started
before this commit; the epoch is not part of any recorded output, so its
counts stand.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
`cargo test --workspace --features test-roms,commercial-roms
--no-fail-fast` on the v3.5.1 cut tree (9dcfaf1): 3,938 passed,
0 failed, 19 ignored across 193 test binaries and doc-test runs
(v3.5.0: 3,920 / 0 / 19 across 193). The commercial suites read 60/0
(external_real_games), 169/0 (external_extended) and 6/0
(external_coverage), as at v3.5.0: no recorded output moved.

Eight of the +18 are named in this release's commits (two MMC5 latch
tests, the rebind release test, five bounded_read tests); the other ten
were not itemised by binary, so they are counted, not attributed.

The run started before the epoch commit (60c8be2). The epoch is not
part of any recorded output, and the tests that read it were re-run
after that commit in a separate target directory: rustynes-core lib
275/0, rustynes-netplay lib 149/0, epoch_fingerprint 2/0.

Verified: markdownlint; release_anchor_audit 18/0;
release_state_prose_audit 9/0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
v3.6.0 carries two NSF player fixes that change what the core produces for
an NSF from the same inputs:

- 60 Hz NSFs call `play` every frame again. From v2.9.8 to v3.5.x the
  driver's first $2000 write fell inside the PPU's post-reset warm-up window
  and was dropped, and the APU frame IRQ was never inhibited, so `play`
  never ran (2106a2a).
- MMC5 NSFs play at all. The driver sat at $5000 with its NMI entry at
  $5015, the MMC5 status port, so the NMI executed the status byte; the
  driver now sits at $5100 (7dfd198).

The bump rule's stated trigger is a moved golden or snapshot, and none moved:
no golden, commercial snapshot or epoch-fingerprint row covers an NSF. The
maintainer chose the strict reading for v3.5.1 (a bus value that changes for
the same inputs raises the epoch) and asked for it again here (2026-10-24):
a v3.5.1 movie of an NSF would otherwise replay with different audio and bus
traffic and no warning. With epoch 8 it is refused with both epochs named.

Changes:
- EMULATION_EPOCH 7 -> 8, with its row in the epoch table
  (hardware_options.rs).
- epoch_fingerprint.tsv header: epoch 8. last_release_epoch stays 7 until
  the v3.6.0 cut sets it, as the header documents; the panel's output did
  not move, so no re-bless.
- CHANGELOG [Unreleased] gains a Breaking section; the v3.6.0 plan records
  the decision.

Save states do not carry the epoch and are unaffected; netplay protocol and
.rnm format numbers are unchanged. Cartridge and FDS emulation is untouched.

Verified on this tree: rustynes-core lib 277/0, rustynes-netplay 149/0 plus
its integration binaries, epoch_fingerprint (test-roms) 2/0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
T-MMC5-LATCH-VBLANK, the nametable half. fda79e5 made the MMC5's split
($5202) and ExGrafix CHR-bank overrides apply only inside the
rendering-fetch predicate (`render_window && ppu_rendering`, the one the
B-set choice uses), because the NESdev MMC5 page describes both as
substitutions to background fetches while the PPU renders and gives $2007
"the last set of registers written to". It left the split's other
substitution untouched: `nametable_fetch` returned ExRAM whenever
`split_chr_bank_latch` was set, and the latch survives the frame (it is
cleared only by the next nametable fetch outside the split region).

So a $2007 nametable read after a split frame - in vertical blank, or
inside the next frame with $2001 rendering off - read the split's ExRAM
byte instead of the table $5105 maps there. `peek_nametable`, the $2007
path, goes through `nametable_fetch`, so it inherited the latch. The page
gives the substitution only "when the PPU is rendering in the split
region", so this is the same oracle defect as the CHR half.

How it was found: the MiSTer sibling's new v3.5.1 stimulus,
`mapper5latch490`, reads CHR and the nametable through $2007 after a split
frame and an ExGrafix frame. Its goldens, exported black-box from the
oracle at both 5d441f4 and fda79e5, showed $0B $47 $83 at $2005 / $2123 /
$23C1 where CIRAM holds $05 $23 $C1. The sibling RTL matched the oracle,
so its gate was held back until the oracle moved; it follows this commit
with the pin.

The fix gates the split branch of `nametable_fetch` on the same predicate
as `chr_offset`. The latch itself is left set, as the CHR half leaves it:
the next frame's fetches overwrite it before use.

Tests:
- New `a_split_latch_does_not_reach_a_nametable_read_outside_rendering`
  failed first ("rendering off mid-frame": Some(0xC3) for None), then
  passes; it checks rendering, rendering off mid-frame, rendering back on,
  and vertical blank.
- `split_nametable_fetch_reads_exram_when_active` made its split fetch
  without rendering on; it now opens the rendering window first, which is
  what it models (the sixth such test; fda79e5 adjusted five).
- rustynes-mappers 1,114 passed, 0 failed.
- test-roms + commercial-roms (release): external_real_games 60/0,
  external_extended 169/0, external_coverage 6/0, epoch_fingerprint 2/0,
  mmc5 3/0, mmc5_scanline_irq 4/0. No snapshot or fingerprint moved:
  real games make this read only inside rendering, if at all.
- clippy -D warnings clean for rustynes-mappers (all targets).

The emulation epoch is already 7 for this release (60c8be2), so it does
not rise again; v3.5.0 movies are refused either way.

Docs in the same change: docs/mappers.md (the override paragraph), the
CHANGELOG [3.5.1] Fixed entry, the v3.5.1 release notes, the ROADMAP row
and the v3.5.1 plan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
doublegate and others added 7 commits October 25, 2026 00:48
The noise channel has run at about half its documented rate, roughly an
octave low, on every region since the engine came into RustyNES at v1.0.0
(the code dates from the dba2e75 transplant). PAL's second period also
read 7 where the page gives 8.

Mechanism. The NESdev "APU Noise" page gives the period tables in CPU
cycles: "The period determines how many CPU cycles happen between shift
register clocks. These periods are all even numbers because there are 2
CPU cycles in an APU cycle." `Noise::write_period` loaded the table entry
N straight into `timer_period`, and `clock_timer` runs once per APU cycle
(`apu_phase`, every other CPU cycle) and reloads on the clock after it
reaches 0. So a shift came every N + 1 APU cycles, 2 x (N + 1) CPU cycles,
against the page's N: for index 0, 10 CPU cycles instead of 4; for the long
periods, just over twice the page's figure. The reload is now N / 2 - 1,
which gives N / 2 timer clocks, N CPU cycles, per shift. The tables stay in
the page's units, with their doc comments saying so; the power-on reload
is derived from the table instead of a bare 4.

PAL index 1 was 7. The page gives 8, and its own note that the periods are
all even rules out 7; under the new conversion an odd entry could not even
be represented.

Found by the MiSTer sibling's PAL/Dendy work (its oracle-vs-documentation
findings 9.1 and 9.2). That agent measured both cores black-box and
did not open this file. I read this file and `apu.rs`'s dispatch line
(1249, the per-APU-cycle `clock_timer` calls); apu.rs carries a
Provenance header for its DMC-DMA region, which that line is not part of.

Tests:
- `the_lfsr_shifts_every_table_period_in_cpu_cycles` counts timer clocks
  between consecutive shifts at all 16 indices in both tables and requires
  2 x count == the table entry. It failed first (NTSC index 0: 5 clocks,
  10 CPU cycles, for 4), then passes.
- `the_pal_table_matches_the_page` pins the PAL table to the page and
  both tables to even entries; it failed on index 1, then passes.
- rustynes-apu lib 171 passed; rustynes-apu + rustynes-core 454 passed, 0
  failed.

What moved, and only that: the full test-roms + commercial-roms suite
(release) failed 112 insta snapshots. Every one of them is in
audio_expansion (noise_vrc7, noise_5b, sweep_5b), external_extended (82),
external_real_games (26) or unreferenced_corpus (1). All 114 changed lines
were compared field by field: every difference is `audio_fnv1a64`. No
framebuffer hash, cycle count or sample count moved, as expected, since
software cannot read the LFSR. They were re-blessed with
INSTA_UPDATE=always and re-run green: audio_expansion 25/0, external_extended
169/0, external_real_games 60/0, unreferenced_corpus 4/0. Nothing else in
the workspace failed, apart from ROM-path failures, which were the
worktree lacking the gitignored tests/roms/external/. I linked that from the
main checkout (not committed) and re-ran. AccuracyCoin, the blargg APU
suites and epoch_fingerprint passed unchanged: none measures the noise
pitch.

Epoch. This changes a sample against the last release, which is the
trigger in docs/agents/ci-and-release.md. EMULATION_EPOCH is already 8 on
this branch (3836660, for the NSF fixes) and v3.6.0 is unreleased, so
it does not rise again. The epoch-table row and the CHANGELOG's Breaking
entry now name the noise fix as well.

Save states: a v3.5.x state carries the old-scale `timer_period` until the
game's next $400E write. That is wrong for at most one noise note, and is
not worth refusing every state over. Not changed.

The MiSTer core has the same defect (finding 9.2), and its APU stems will
move when its pin moves to this commit. That fix belongs to the sibling's
v3.6.0 integration and is not part of this commit.

Docs: docs/apu-2a03.md ($400E row), the CHANGELOG [Unreleased] Fixed and
Breaking entries, and the hardware_options.rs epoch table.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
…cision

Two entries in the v3.6.0 plan's "For the maintainer" section.

The noise fix (1491d3a) moves every game's noise pitch, and no test ROM
measures that pitch. All 112 audio checksums were re-recorded to the new
output, so the suite cannot catch an error in the reading. The entry
therefore records all three statements on the NESdev pages that the fix
rests on:
- the period sentence on the APU Noise page;
- that page's own frequency table: 447,443.2 Hz for NTSC index 0 is
  1,789,773 / 4, and the old code gave 178,977 Hz; PAL's 415,651.8 Hz is
  1,662,607 / 4;
- the APU page's statement that the noise timer is clocked every second
  CPU cycle.
It also asks for a listening comparison against a trusted emulator before
the release, prompted by a side agent's review of the change.

The PAL entry records the maintainer's 2026-10-25 direction: 50 Hz
consoles ship at full speed on the MiSTer core, through an ADR 0039 clock
amendment, and a timing closure that fails is a blocker rather than a
caveat to ship.

Docs only; markdownlint passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
Records the decision the MiSTer sibling implemented in feat/v3.6.0-pal
(b7f6d7c): a PAL or Dendy console gets its own 26.6017125 MHz master clock,
with the SDRAM at exactly 3x (79.8051375 MHz), switched at run time through
a reconfigurable PLL. NTSC keeps 21.477272 / 85.909088 MHz (4x) bit for bit.

Why an ADR, and why here: ADR 0039 fixed the SDRAM clock at 4x the master
clock, and the project's ADR rule forbids editing an accepted ADR, so the
one respect in which this departs from it (3x at 50 Hz) is recorded as an
amendment. The decision belongs to the maintainer's instruction of
2026-10-25 ("we should accomplish any clock changes necessary, so there are
no slowdowns in PAL and Dendy console modes"), quoted verbatim in the
Status section. Before it, a 50 Hz console would have run at 80.7 % speed
on a board (40.4 frames a second, audio 3.7 semitones flat).

The ADR keeps the two rejected alternatives with their measured reasons:
SDRAM at 4x is 106.4 MHz, where CL2 is illegal (tCK >= 10 ns), four timings
need another cycle and the SDRAM paths measured about 11 ns against 9.4;
and a single faster base clock with fractional enables would gate every
register under nes_top and jitter NTSC against its exact 4 : 1 slot
schedule.

Consequences recorded as measured in the sibling: the 3x deadlines (PRG 21,
CHR 24) and the budget gate's limits (CHR <= 24, PRG <= 20 at 50 Hz); timing
analysed in both configurations on one netlist, with seeds 1 and 3 closing
every corner of both builds and seed 2 missing by 0.472 ns off the die in
the framework's HDMI scaler; the paused clocks during a switch; and the
list of what only a board can establish. No board has run any of it, and
the PAL yc.txt entry is not written; both are stated in the ADR.

The draft came from the sibling's agent (it numbered it 0050 because 0049
is the DAP ADR on this branch); its quotation of the maintainer was a
paraphrase and is replaced by the exact words, and the link to ADR 0039 is
corrected to its real filename. DOCUMENTATION_INDEX's ADR row now reads
0001-0050.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
The CHANGELOG [Unreleased] and the v3.6.0 plan, for the sibling's half of
v3.6.0 (RustyNES_MiSTer feat/v3.6.0, which merges v3.5.1 into the PAL and
Dendy work and takes the noise fix, 32504e5 + 7f9cd77; not pushed).

CHANGELOG:
- Breaking: a v3.5.x MiSTer save state is refused on both builds. PAL and
  Dendy widen the PPU access counter, so the chain is 4,098 bits on the
  die and 4,737 off it, and the layout fingerprint moved; an old state is
  refused for its header rather than misread.
- Added: PAL and Dendy consoles on the MiSTer core at full speed (the
  Region OSD option; ADR 0050: a 50 Hz console runs at 26.6017125 MHz with
  the SDRAM at 3x; NTSC bit-identical). No board has run it.
- Fixed: the noise entry gains its black-box measurement (Nestopia's
  libretro core, run as a black box on a short-mode noise ROM, shifts
  every 160, 202, 380 and 762 CPU cycles at periods 7, 8, 10 and 12, as
  v3.6.0 does; v3.5.1 shifted every 322, 406, 762 and 1,526), and the line
  that the MiSTer core had the same defect and takes the same fix on both
  builds.

Plan: sprints S1 and S2 marked done (2026-10-23; the device rows skipped,
no device on this machine); the noise bullet records the measurement and
that it is a measurement, not a listening test: Mesen's libretro core
crashed under the harness and was not measured, and checking real games
by ear is still the maintainer's; the PAL bullet records sibling b7f6d7c
and ADR 0050 with the timing summary. The Row 6 record and the S3 status
are written after the seed sweep, which is running.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
The version bump, the anchors and the release notes for v3.6.0, the sixth
release of the v3.1 to v4.0 line, on v3.5.1 "Reprise": the noise channel
at its documented pitch in both cores, NSF playback fixed, PAL and Dendy at
full speed on the MiSTer core, rustynes-dap, audio stems, NSF ripping,
libretro Core Options v2 and the emulation options in libretro states,
Android API 37, iOS durable saves.

The codename "Octave" is PROVISIONAL: the maintainer chooses codenames at
the cut and was not asked (the request was to work through without
stopping). It names the release's most audible change, the noise channel
moving up about an octave. Checked unused (as a quoted codename) in
CHANGELOG.md, CHANGELOG-FULL.md and VERSION-PLAN.md. Renaming it is the
CHANGELOG header, the notes title, the two VERSION-PLAN rows, the ROADMAP
sentence and the anchors bump_release.py wrote.

bump_release.py --apply moved every anchor it owns (Cargo.toml and both
lockfiles, the cosim crate, the libretro .info display_version, Android's
versionCode, the iOS project, the hosted Worker's package files and
RA_USER_AGENT, and the prose anchors in AGENTS, ARCHITECTURE, OVERVIEW,
README, ROADMAP, SECURITY, SUPPORT and STATUS), with the lead sentence
describing v3.6.0 and v3.5.1's demoted to "Built on". The dry run reported
nothing it refused. By hand:
- the CHANGELOG [3.6.0] header (the entries were already under
  [Unreleased]; the heading is kept, empty);
- to-dos/ROADMAP.md's release-line sentence: v3.5.1 loses "the current
  release", v3.6.0 gains it;
- VERSION-PLAN.md: the forward row (status and scope as built) and a new
  release-table row carrying "(current)", moved off v3.5.1;
- crates/rustynes-test-harness/golden/epoch_fingerprint.tsv:
  last_release_epoch 7 -> 8. EMULATION_EPOCH is 8 (3836660), so from
  here any moved fingerprint fails until the epoch rises again, which is
  the rule (ADR 0045). epoch_fingerprint passes 2/2 with test-roms.
- .github/release-notes/v3.6.0.md (1,855 words, one paragraph per line):
  the fixes in user terms; how the noise fix was checked (the page's three
  statements, the Nestopia black-box measurement, Mesen not measured, and
  that nobody has listened yet); the additions with what is untried (no
  released GDB/LLDB has a MOS target, no VS Code extension); the changes,
  libretro states +16 bytes among them; the DualSystem overclock
  round-trip finding; the DAP provenance question, stated as a question
  for review and certified neither way; the MiSTer section (no board run,
  the yc.txt PAL entry not written, bitstreams compiled at release time).

Compatibility: emulation epoch 8 refuses v3.5.1 movies and netplay
peers; netplay protocol 9 and .rnm format 7 unchanged; desktop and
libretro states from v3.5.1 load (libretro states written now are 16
bytes longer); MiSTer v3.5.x states are refused (the sibling's change).

Verified on this tree: release_anchor_audit 18/0, release_state_prose_audit
9/0, release_notes_render_audit 4/0, contribution_checklist_audit 14/0,
libretro_info_audit 6/0, epoch_fingerprint 2/0 (test-roms); pre-release.sh
"nothing definitively wrong". The full gates and the test-roms +
commercial-roms suite run on this commit next; their counts go into
docs/STATUS.md in the commit after this one.

NOT pushed, by the maintainer's instruction. Depends on v3.5.1
(feat/v3.5.1, also unpushed) merging first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
The full suite on the v3.6.0 cut (719e079), cargo test --workspace
--features test-roms,commercial-roms --no-fail-fast, first read 4,029
passed, 1 failed, 20 ignored across 200 binaries. The failure was
external_coverage (5 passed, 1 failed): its committed snapshots for the
staged local dumps (893 tracked; the dumps themselves are not committed)
were never re-recorded after the noise fix 1491d3a.

CORRECTION. 1491d3a re-recorded 112 audio checksums (test-ROM snapshots
and the real-games suite) and said that was every moved output. It was not:
the coverage suite's 558 had moved too. The earlier gates at fd745cf ran
without commercial-roms, which is why nothing caught it then.

What moved, measured over every .snap.new the run wrote: 558 files, and in
each the only differing line is audio_fnv1a64. No framebuffer checkpoint,
cycle count, sample count or frame count moved in any of them, which is the
noise fix's signature (audio only). Each file here is HEAD's text with that
one line replaced, so insta's metadata and every other line are untouched
(558 insertions, 558 deletions). The previous 558 are kept outside the
repository (salvaged/evidence/v3.6.0-noise/external-coverage-pre-noise/).

Verified: with the new checksums in place, external_coverage passes 6/0
(5,820 s). The full count on the cut is therefore 4,030 passed, 0 failed,
20 ignored; external_real_games 60/0 and external_extended 169/0 passed in
the first run. That rerun was made with insta's own accepted files; the
files committed here differ from those only in insta's metadata header
(an assertion_line it adds), which insta does not compare. Not re-run
after that header-only change.

The epoch rule holds: EMULATION_EPOCH is 8 and these moves belong to the
noise fix that raised it; epoch_fingerprint passes 2/0.

Docs: docs/STATUS.md gains the v3.6.0 top-line counts with the above;
the CHANGELOG noise entry and the release notes now say 558 more checksums
moved, each in its audio alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
The plan's record of row 6 (FB-7 PAL and Dendy with T-MISTER-VMODE), which
landed in the MiSTer sibling on feat/v3.6.0 (not pushed): the PAL and Dendy
work ending b7f6d7c, the merge of v3.5.1 (32504e5), the noise fix with the
pin moved (8a40ad4) and the seed pin (dd0cb60).

Each planned gate with its measured result: the PAL goldens (23 stems, 280
in all, regenerated at fd745cf; apart from the manifests only six APU
traces moved, in the noise channel alone); the PAL bus and framebuffer
gates; a caught mutant per table (the region table 41 of 41 on the merged
tree, and v3.5.1's MMC5 table 10 of 10); both ladders, one frozen-copy run
each with nothing skipped (353 / 0 / 4 on the die, 404 / 0 / 10 off it);
and the seed sweep, where seed 4 is the only seed that closes both builds
(+0.010 / +0.092 on the die, +0.118 / +0.121 off it).

Beyond the row: full-speed 50 Hz (ADR 0050), by the maintainer's direction
of 2026-10-25. Skipped, with what unblocks each: any board run, and the PAL
yc.txt entry. Sprint 3.6-S3 is marked done with that caveat.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
@coderabbitai

coderabbitai Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

Walkthrough

Warning

Review details and warnings were omitted to fit the comment limit.

@context7

context7 Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

Docs7 for doublegate/rustynes

Result Status Action
Deployment ✅ Ready Open preview
Content review ➖ Did not run. This site has no agent runs available this month. Wait for the monthly reset or check your Docs7 plan. —

Commit cc30c0a · Updated 2026-10-29 23:54 UTC · View build details

@cursor

cursor Bot commented Oct 29, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: 841e4ae3-990b-4451-977c-350c695bb569)

@socket-security

socket-security Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addedcargo/​gimli@​0.34.010010093100100
Addedcargo/​object@​0.40.09910093100100

View full report

The Security workflow on the release PR failed on two advisory-database
entries that appeared after the cut, not on anything this branch
changed. main's own scheduled Security run (2026-10-26) fails the same
way.

- RUSTSEC-2026-0385 (2026-10-23), ctutils 0.4.3: non-constant-time
  mask generation and u128 comparisons; fixed in >= 0.4.4. It reaches
  the lockfile only through `digest`; `cargo tree -i ctutils --target
  all` matches no package, so it appears to sit behind a `digest`
  feature this build does not enable. Bumped anyway, because cargo
  audit and cargo deny read the lockfile, not the build graph.
- clipboard-win 5.4.1 is yanked (cargo deny `yanked` error). It is
  Windows-only, via arboard in rustynes-frontend.

`cargo update -p ctutils -p clipboard-win`: two patch-level moves, both
resolved against the Rust 1.99 pin, 4 lines of Cargo.lock. Nothing else
moved.

Verified locally: `cargo deny check advisories` ok; `cargo audit` reports
no vulnerability; `cargo check --workspace --all-targets --locked`
clean. Not verified locally: clipboard-win builds on Windows only, so
its build is the release/* PR's windows-latest leg.

No CHANGELOG entry: no user-visible behaviour changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
@cursor

cursor Bot commented Oct 29, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: 2ce4ba20-4dbe-48ba-b2fb-46be17cf0b0d)

@github-actions

github-actions Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

Antigravity review (Gemini via Ultra)

Ships the v3.6.0 "Octave" release (and v3.5.1 "Reprise"), fixing APU noise period scaling and PAL index 1, restoring 60 Hz and MMC5 NSF playback, gating MMC5 split/ExGrafix CHR and nametable substitutions to active rendering (EMULATION_EPOCH 8), adding the rustynes-dap source-level debugger, per-channel audio stem export and trace-assisted NSF ripping, libretro Core Options v2 with float audio and emulation options in save states, bounded regular-file reads in the desktop frontend, and Android API 37 / iOS durable-write and periodic FDS persistence updates.

Blocking issues

None found.

Suggestions

  • Check that a line ends with \n before treating the next read as a new DAP header: In read_message, r.by_ref().take(1024).read_line(&mut line) returns Ok(1024) without a trailing \n when a single header line is at least 1,024 bytes long. Instead of rejecting the overlong header line, the next loop iteration parses the remainder of the same line as a fresh header (which can match an injected Content-Length: or blank line and desynchronize the stream). Reject any non-EOF header read where !line.ends_with('\n').
  • Enforce the regular-file check (is_file()) in rustynes-dap's bounded file readers: While this PR adds bounded_read.rs (T-FS-READ-REGULAR) to verify std::fs::metadata(path)?.is_file() before File::open, read_bounded (used for client-supplied program and debugInfo paths in launch) and read call File::open(path) directly without checking metadata(path)?.is_file(), so a FIFO path blocks open indefinitely and a device node such as /dev/zero streams up to MAX_ROM_BYTES / MAX_DEBUG_FILE_BYTES (16 MiB) before failing.
  • Verify the CPU actually entered target before running to frame_hook on StepKind::In: In Engine::step_line, when self.peek(pc) == 0x20 (JSR) and kind == StepKind::In, self.exec_one() executes a single step and then immediately calls self.run_to(hook, 0, poll) if target has a frame_hook != target. If an NMI or IRQ is serviced on that step, the CPU vectors to the interrupt handler rather than target, yet step_line runs until target's prologue hook instead of stopping at the next source line. Guard the run_to(hook, ...) call on self.pc() == target.
  • Handlecc65 prologues with multiple stack adjustments or void parameter lists: In Engine::place_hooks, frame_hook only advances past a single leading JSR in PUSH_ROUTINES. For a non-__fastcall__ function or a multi-parameter function whose prologue emits more than one parameter push before local allocation, c_sp is sampled after only the first push, leaving FrameBase::ZpPointer offsets off by the remaining pushed bytes.
  • Do not silently discard metadata write errors after updating a save-state slot on iOS: In SaveStateManager.write and SaveStateManager.importRemote, try? DurableWrite.write(encoded, to: metaURL(sha: sha, slot: slot)) swallows failures when writing the .json sidecar after the .rns state has already been replaced, which leaves the previous slot's SlotMeta (frame and savedAt timestamp, used by CloudKit last-writer-wins reconciliation) paired with the new state blob. Likewise, AppModel.saveSlot uses try? saveStates.write(...) and unconditionally triggers cloudSaveStates.upload(sha: sha, slot: slot) even when the local write fails.

Nitpicks

  • LocalNetwork.promptedThisRun: Keeping promptedThisRun on the process-wide object LocalNetwork rather than scoping it to the Activity instance means an Activity recreation within the same process will treat the permission as already prompted in the new session.
  • stems_cli::write_wav_i16_mono: Clamping data_bytes to u32::MAX and riff_size with saturating_add(36) while still writing the full samples slice would emit a malformed WAV header if the sample buffer ever exceeded 4 GiB (unreachable under MAX_FRAMES = 36_000, but returning an error on u32 overflow is cleaner).

Automated first-pass review by agy on a self-hosted runner -- not a human review.

Earlier review rounds (newest first)
Round reviewed at 2026-10-30 00:24 UTC

Antigravity review (Gemini via Ultra)

This pull request cuts RustyNES v3.6.0 "Octave" (including v3.5.1 "Reprise"), fixing APU noise-channel period clocking and MMC5 non-rendering CHR/nametable banking, restoring 60 Hz and MMC5 NSF playback, adding the rustynes-dap source-level debugger (DAP + GDB RSP stub), adding rustynes stems and rustynes nsf-rip, exposing Libretro Core Options v2 and float audio, and adding Android API 37 local-network permission gating, non-blocking AudioTrack pacing, and iOS durable file writes.

Blocking issues

  • QStartNoAckMode in the GDB stub never disables packet acknowledgements (gdb.rs, Stub::run): Stub::run advertises QStartNoAckMode+, replies "OK", and sets self.ack = false, but self.ack is never read anywhere. Packet +/- acknowledgements are written unconditionally by the background gdb-reader thread in read_packets via its own cloned TcpStream, which shares no state with Stub. Any RSP client that enables no-ack mode and stops expecting leading + bytes before $ will desynchronize or treat + as garbage. The E2E test in gdb_e2e.rs missed this because Gdb::recv loops until b'$', silently discarding unexpected + bytes even after self.ack = false.
  • Mid-run DAP requests in Mode::Continue perturb execution, can skip breakpoints, and break the frame-hook no-perturbation contract (Server::run, Engine::resume):
    1. While self.mode == Mode::Continue, any incoming message on rx (such as setBreakpoints, threads, or a rejected stackTrace) causes poll() to return true and Engine::resume to return Stop::Pause. For any request other than pause, Server::run handles the request while keeping self.mode = Mode::Continue, then re-enters Engine::resume on the next loop iteration. Because Engine::resume unconditionally begins with self.exec_one() (intended to step off a breakpoint when resuming from a stopped state) before checking self.user.contains(&pc), a breakpoint newly set at the current PC—or a user breakpoint reached on the same run_frame() return where poll() became true at line 482—is stepped past without stopping.
    2. When run_frame() stops on an internal frame hook (hit == true) and poll() is also true (engine.rs), self.nes.take_break_hit() has already armed skip_breakpoint_once inside Nes. Returning Stop::Pause and then re-entering Engine::resume executes self.exec_one() (step_instruction(), which does not consume skip_breakpoint_once), leaving skip_breakpoint_once armed for the next run_frame() and breaking the invariant that a debug session never perturbs execution or misses the next breakpoint hit.

Suggestions

  • Check is_file() before reading user-supplied paths in rustynes-dap (dap.rs, main.rs): This PR introduces bounded_read.rs in rustynes-frontend specifically to reject FIFOs and device nodes before File::open can block indefinitely, but read_bounded and read in rustynes-dap call File::open(path) directly without checking std::fs::metadata(path)?.is_file().
  • Sync parent directory after renaming staged WAV files in WavWriter::finish (stems_cli.rs): WavWriter states that it applies the same durability rule as atomic_write, and calls f.sync_all() before std::fs::rename(&self.staged, &self.target), but unlike atomic_write and DurableWrite.swift, it does not sync_all() the parent directory after the rename.
  • Strip or normalize local developer paths in committed debug fixture (dapdemo_cc65.dbg, dapdemo_cc65.dbg, build.py): dapdemo_cc65.dbg embeds absolute host paths (/home/parobek/.local/share/cc65/... and /home/parobek/Code/cc65/...), leaking local filesystem layout into the repository and making rebuilt fixtures differ across machines.

Nitpicks

  • Redundant match in Engine::step_line (engine.rs): return match self.run_to(hook, 0, poll) { Stop::Step => Stop::Step, other => other }; is a no-op match and can be return self.run_to(hook, 0, poll);.

Automated first-pass review by agy on a self-hosted runner -- not a human review.

This branch was successfully deployed

1 active deployment
Docs7 Preview: release/v3.6.0 — cc30c0af Deployed Oct 29, 2026 by context7[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant