Repository navigation
release: v3.6.0 "Octave" - #631
doublegate wants to merge 38 commits into
Conversation
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
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
WalkthroughWarning Review details and warnings were omitted to fit the comment limit. |
|
Docs7 for doublegate/rustynes
Commit |
Bugbot couldn't run - usage limit reachedBugbot 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) |
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
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
Bugbot couldn't run - usage limit reachedBugbot 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) |
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 ( Blocking issuesNone found. Suggestions
Nitpicks
Automated first-pass review by Earlier review rounds (newest first)Round reviewed at 2026-10-30 00:24 UTCAntigravity 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 Blocking issues
Suggestions
Nitpicks
Automated first-pass review by |
v3.6.0 "Octave"
The user-facing account is
.github/release-notes/v3.6.0.mdand 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,
maingets merged into this branch so the diff shows only v3.6.0.Breaking
Listen before merging
1491d3a1changes 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)
1491d3a1noise LFSR period (with PAL's second period, 7 -> 8);7541d77astem export and trace-assisted NSF ripping;60a03280stems staged and renamed into place.2106a2a6playruns every frame at 60 Hz again (frame IRQ inhibited, NMI enabled after the PPU warm-up);7dfd1988the driver moved off the MMC5 status port;38366602epoch 8.87ac335erustynes-dap, a Debug Adapter Protocol server and a loopback-only GDB stub (ADR 0049);190b7378cc65 and llvm-mos fixture builds and a CI job.0ad500c1Core Options v2 with a v0 fallback;7d69818aVs. DIP switches;85d8780boverclock and sprite limit, kept in states;113b1298float audio negotiation (int16 stays the fallback);ca585fe4the.infoaudit reads every core module;a9fa8f45Switch, Vita and 3DS recorded as no-go.a3c464c6Android API 37, the Nearby-devices permission for LAN play, paced non-blocking audio;569fed41iOS durable writes and a once-a-second FDS disk check.cdc8218d,fd745cf0,acd79252(ADR 0050, full-speed PAL and Dendy on MiSTer),b3811760,719e079dthe cut,ecda19e9.5cd47db3re-records 558 coverage-suite audio checksums that1491d3a1missed (it said 112 was all of them). Each changed file differs from the previous version only inaudio_fnv1a64.For the maintainer
rustynes-dap's cc65 reader, the agent that built it had read the desktop source-map module, which carries a Mesen2Provenance: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 aProvenance:header. Whether that needs a header, a provenance record and aNOTICEentry 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 after5cd47db3. The first run was 4,029/1/20, the failure being those 558 checksums. Commercial suites 60/0, 169/0, 6/0.rustynes-cosim, rustdoc-D warnings, theno_stdcore: all clean..info6/0, epoch fingerprint 2/0.pre-release.sh: nothing definitively wrong.Not done, on purpose
rustynes-dap(no released GDB or LLDB has a MOS target), RetroArch's float-audio path.yc.txtentry.🤖 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-dapcrate in the workspace, andEMULATION_EPOCH8 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
playevery frame; MMC5 driver relocated off$5015).New tools:
rustynes-dap(DAP + read-only GDB stub for cc65/llvm-mos),rustynes stems, andrustynes nsf-rip, with a new CI jobdap-fixturesthat 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_NETWORKfor 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
versionCode30600.Reviewed by Cursor Bugbot for commit cc30c0a. Bugbot is set up for automated code reviews on this repo. Configure here.