Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions .github/release-notes/v2.5.9.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# v2.5.9 "Overture"

**Rung 4 opens: the two pulse channels, the frame counter, and four ROM defects the stimulus measurement found first.**

An overture is the part that plays before the rest of the orchestra arrives — which is what two of five channels is.

## The partition, fixed before the rung

The APU is the hardest chip in the console to gate honestly, because what it *produces* is an analog level and what an emulator computes is a number. Those are not the same claim, so the sibling's `docs/rung4-apu.md` was written **before** any `rtl/apu2a03.sv` existed:

| surface | status |
| --- | --- |
| `$4015` read value, per CPU cycle | **gate** — a real CPU-visible register |
| the APU's `/IRQ` line | **gate** — a physical pin |
| each channel's **integer** DAC input | **gate** — documented hardware value |
| `MixRecord::mixed` / `::external` (`f32`) | *diagnostic* — RustyNES's non-linear mixer, decimator, expansion gain |
| frame-sequencer step index | *diagnostic* — internal, no pin |
| `apu_phase` | *diagnostic* — the emulator's name for a divider phase |

A field that exists only because RustyNES chose to model something that way never becomes a gate, however convenient.

## What the stimulus measurement caught, before any gate ran

Four ROM defects, none of which a gate would have attributed correctly:

- **Length index 3 is `2`, not `254`.** The table alternates long and short; the index is not the count. Pulse 1 never left zero.
- **The 6502 boots with I set.** Without `CLI`, **zero** IRQs are taken despite five real line edges — and the interrupt *sequence* is half of what the ROM gates.
- **Two channels at the same volume** are indistinguishable in a channel-level golden — the same shape as v2.4.7's store/load pair sharing one addressing mode.
- **Power-on work RAM is seeded, not zeroed.** An uninitialised counter byte came up `0x7D`, so the handler's `CMP #3` never matched and the inhibit write never happened.

## Findings in the DUT

- **The duty sequencer counts up.** Counting down gave the right period and the right levels with the wrong phase — pulse 1 three steps late, pulse 2 five.
- **The 4-step constants must be consistently 0-based.** `fc_count` reads V on tick V+1, so each documented step is V−1. Three of four were; the last was the wiki's own number, putting the frame IRQ **3 CPU cycles late** (29,830 against 29,827, measured).
- **`$4017` bit 7 clocks a quarter and half frame *immediately*.** A latched flag clocked on the next APU tick left exactly two divergent cycles at a length expiry.
- **The `$4017` reset delay depends on bit 7.** The wiki's "3 or 4 CPU clock cycles" does not say which applies when. Each constant fixed one ROM and broke the other; a parity rule on `apu_phase` separated nothing, because both ROMs' writes land on the same phase. Bit 7 — which also fires the immediate clock — does separate them.

## Gates

| gate | result |
| --- | --- |
| `apulen027` channel levels · bus | **178,668** · **178,668** |
| `apupulse026` bus (`$4015`, frame IRQ, interrupt sequence) | 3 of 178,668 |
| `apupulse026` channel levels | 1,000 of 178,668 — **500 runs of exactly 2** |

**Nine of ten mutations CAUGHT.** The two that weren't **both indicted the stimulus, not the gate** — a halt flag whose channel's length was too long to expire in the window, and an inhibit bit only ever set in 5-step mode where the IRQ cannot fire anyway. Both ROMs were changed; both are now caught. The tenth is the sweep mute's threshold, out of stimulus regardless (both ROMs use periods 64 and 84, so any threshold below 64 is inert) and belonging to the sweep unit this rung defers.

## The residual, characterised

`apupulse026`'s 1,000 divergent cycles are **500 runs of exactly two cycles, one per pulse edge** — a uniform one-APU-tick offset, not a structural fault.

It is a **phase sensitivity the first stimulus hid.** Adding the five-cycle counter initialisation — an *odd* number — flipped which `apu_phase` the `$4003` writes land on. Before that change both ROMs were exact. Two candidate fixes were tried and **both rejected by measurement**: flipping the pulse timer's tick phase halved one ROM (1,000 → 500) while breaking the other (0 → 172), and reloading the period divider on `$4003` — against the wiki's explicit "the period divider is not reset" — made both worse. The wiki is right.

Same shape as v2.5.7's `PPU_LEAD` and v2.5.8's cycle split: one quantity, one tick, exposed only when a stimulus finally lands on the other phase. **v2.6.0's first item**, with the ROM that exposes it already written.

## Oracle side

`--apu-trace` on `nes_golden_export` exports the five integer channel levels per CPU cycle, with an **explicit** cycle per record — they are drained per frame, so an index-implied cycle would be wrong the moment a frame boundary shifted anything. `rustynes-cosim` enables `debug-hooks`; that this changes no emulated behaviour is **verified, not assumed** — `obs.bin`, `index_fb.bin` and `ram.bin` are byte-identical with and without it.

**Zero emulation-core changes** — the diff is the excluded `rustynes-cosim` crate plus documentation — so **AccuracyCoin holds 141/141 (100.00%, RAM decoder)** and nestest stays 0-diff by construction.

Sibling: `RustyNES_MiSTer@d6bef7d`. Full findings: the sibling's `docs/rung4-apu.md`.

## Next

**v2.6.0 — the `$4003` write-parity residual first, then triangle and noise.**
4 changes: 2 additions & 2 deletions AGENTS.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

**Document Version:** 2.1.0
**Last Updated:** 2026-08-20
**Applies to:** RustyNES v2.5.8 (the scheduling model is v2.0.0 "Timebase" onward)
**Applies to:** RustyNES v2.5.9 (the scheduling model is v2.0.0 "Timebase" onward)

This document fixes the high-level architecture of RustyNES. The per-subsystem specs under `docs/` (`cpu-6502.md`, `ppu-2c02.md`, `apu-2a03.md`, `mappers.md`, `scheduler.md`) take these decisions as given and elaborate one chip each. After reading this you should know the workspace shape, the scheduling model, the public boundary, and the load-bearing invariants. The canonical, always-current architecture spec is [`docs/architecture.md`](docs/architecture.md); this file is the top-level companion.

Expand Down
78 changes: 78 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,84 @@ cycle-accurate core later replaced.

## [Unreleased]

## [2.5.9] - 2026-08-24 - "Overture" (rung 4 opens: the two pulse channels, the frame counter, and four ROM defects the stimulus measurement found first)

### Added

- **Rung 4 opens.** `rtl/apu2a03.sv` in the sibling repository gains both pulse
channels — timer, 8-step duty sequencer, length counter, envelope and the
sweep **mute** — plus the frame counter in both modes with its IRQ, and the
`$4015`/`$4017` register file. Written from the NESdev wiki; no emulator or
HDL source consulted, per ADR 0037. Triangle, noise and DMC are v2.6.0/v2.6.1
and are driven to zero.
- **The gate/diagnostic partition, written BEFORE the rung** (the sibling's
`docs/rung4-apu.md`). The APU is the hardest chip in the console to gate
honestly, because what it *produces* is an analog level and what an emulator
computes is a number. **Gates:** the `$4015` read value, the `/IRQ` pin, and
each channel's **integer** DAC input, all per CPU cycle. **Diagnostics:**
`MixRecord`'s `f32` `mixed`/`external` (RustyNES's non-linear mixer,
decimator and expansion gain — a modelling choice), the frame-sequencer step
index (internal, no pin), and `apu_phase`.
- **`--apu-trace` on `nes_golden_export`**, exporting the five integer channel
levels per CPU cycle as 16-byte records with an **explicit** cycle — the
records are drained per frame, so an index-implied cycle would be wrong the
moment a frame boundary shifted anything. `rustynes-cosim` enables
`debug-hooks`; that this changes no emulated behaviour is **verified, not
assumed** — `obs.bin`, `index_fb.bin` and `ram.bin` are byte-identical with
and without it.
- **Two rung-4 stimulus ROMs** (`apupulse026`, `apulen027`) covering both
pulses at different periods, duties and volumes, the frame IRQ and its
interrupt sequence, the 4-step and 5-step modes, the inhibit flag, and a
length counter that expires against one that is halted.

### Fixed

- **The duty sequencer counts up.** Counting down gave the right period and the
right levels with the wrong phase — pulse 1 three sequencer steps late and
pulse 2 five.
- **The 4-step sequence constants must be consistently 0-based.** `fc_count`
reads V on tick V+1, so each documented step is V−1. Three of four were and
the last was written as the wiki's own number, putting the frame IRQ **3 CPU
cycles late** (29,830 against 29,827, measured) and the last length-counter
clock of every frame with it.
- **`$4017` bit 7 clocks a quarter and half frame *immediately*** — a latched
flag clocked on the next APU tick left exactly two divergent cycles at a
length expiry.
- **The `$4017` sequencer-reset delay depends on bit 7.** The wiki says "3 or 4
CPU clock cycles" without saying which applies when, and at this resolution
the two are distinguishable: each constant fixed one ROM and broke the other,
and a parity rule on `apu_phase` separated nothing because both ROMs' writes
land on the same phase. Bit 7 — the bit that also fires the immediate clock —
does separate them.

### Verified

- **`apulen027` is exact on both surfaces** (178,668 cycles each), and
`apupulse026`'s bus surface is 3 of 178,668.
- **`apupulse026`'s channel levels are 1,000 of 178,668 — 500 runs of exactly
two cycles, one per pulse edge.** A uniform one-APU-tick offset, not a
structural fault, and a **phase sensitivity the first stimulus hid**: adding a
five-cycle counter initialisation — an *odd* number — flipped which
`apu_phase` the `$4003` writes land on. Two candidate fixes were tried and
**both rejected by measurement**; the wiki is right that the period divider is
not reset. Carried to v2.6.0 with the ROM that exposes it already written.
- **Nine of ten mutations CAUGHT.** Two were NOT CAUGHT on the first pass and
**both indicted the stimulus rather than the gate** — a halt flag whose
channel's length was too long to expire in the window, and an inhibit bit only
ever set in 5-step mode where the IRQ cannot fire anyway. Both ROMs were
changed and both are now caught. The tenth is the sweep mute's threshold,
out of stimulus regardless (both ROMs use periods 64 and 84, so any threshold
below 64 is inert) and belonging to the sweep unit this rung defers.
- **The stimulus measurement found four ROM defects before any gate ran** —
length index 3 is **2**, not 254; the 6502 boots with I set, so without `CLI`
zero IRQs are taken despite five real line edges; two channels at the same
volume are indistinguishable in a channel-level golden; and power-on work RAM
is **seeded, not zeroed**, so an uninitialised counter came up `0x7D` and the
handler's `CMP #3` never matched.
- **Zero emulation-core changes.** The oracle-side diff is the excluded
`rustynes-cosim` crate plus documentation, so **AccuracyCoin holds 141/141
(100.00%, RAM decoder)** and nestest stays 0-diff by construction.

## [2.5.8] - 2026-08-24 - "Blanking" (VBlank, NMI and the PPUSTATUS race close rung 3 — and both fixes were deletions)

### Added
Expand Down
38 changes: 19 additions & 19 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ default-members = ["crates/rustynes-libretro"]
# `release-auto.yml` reads the `## [X.Y.Z]` line for BOTH the release body
# fallback and the title codename — so the date and quoted codename are load-
# bearing, not decoration.
version = "2.5.8"
version = "2.5.9"
edition = "2024"
rust-version = "1.96"
license = "GPL-3.0-or-later"
Expand Down
6 changes: 3 additions & 3 deletions OVERVIEW.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

**Document Version:** 2.1.0
**Last Updated:** 2026-08-24
**Applies to:** RustyNES v2.5.8
**Applies to:** RustyNES v2.5.9

---

Expand All @@ -22,9 +22,9 @@

RustyNES is the **definitive NES emulator for the modern era** — combining cycle-perfect accuracy with a complete contemporary feature set and the safety guarantees of Rust. It is more than an emulator: it is a platform for NES preservation, competitive online play, tool-assisted speedrunning, and homebrew development.

As of **v1.0.0**, that vision was realized: RustyNES clears the Mesen2 / higan / ares accuracy bar, ships a polished desktop application and a browser build, and supports the full platform surface — netplay, achievements, TAS movies, a debugger, FDS, and arcade (Vs. / PlayChoice-10) hardware. Since then the additive v1.x line added three more platforms (native Android, iOS / iPadOS, and a Libretro / RetroArch core), **v2.0.0 "Timebase"** replaced the scheduler substrate with the one-clock / every-cycle-bus-access model (ADR 0029 — the one deliberate breaking release), and the v2.1.x → v2.3.x lines deepened accuracy, presentation, and analysis tooling. The current release is **v2.5.8 "Blanking"**. The never-tagged v2.4.0 "Concordance" shipped inside **v2.4.1 "Fabric"** — this sentence had attached that fact to whichever release was current, carried forward by three mechanical version bumps, and said it of v2.4.2, v2.4.3 and v2.4.4 in turn.
As of **v1.0.0**, that vision was realized: RustyNES clears the Mesen2 / higan / ares accuracy bar, ships a polished desktop application and a browser build, and supports the full platform surface — netplay, achievements, TAS movies, a debugger, FDS, and arcade (Vs. / PlayChoice-10) hardware. Since then the additive v1.x line added three more platforms (native Android, iOS / iPadOS, and a Libretro / RetroArch core), **v2.0.0 "Timebase"** replaced the scheduler substrate with the one-clock / every-cycle-bus-access model (ADR 0029 — the one deliberate breaking release), and the v2.1.x → v2.3.x lines deepened accuracy, presentation, and analysis tooling. The current release is **v2.5.9 "Overture"**. The never-tagged v2.4.0 "Concordance" shipped inside **v2.4.1 "Fabric"** — this sentence had attached that fact to whichever release was current, carried forward by three mechanical version bumps, and said it of v2.4.2, v2.4.3 and v2.4.4 in turn.

> RustyNES's emulation core descends from an extensively-documented accuracy program. Where this and related docs reference deep "v1.x"/"v2.x" engine narrative, read it as upstream engine lineage (engineering history), not as RustyNES release versions. Two distinct "v2.0"s exist and must not be conflated: the engine-lineage v2.0 master-clock work shipped as RustyNES **v1.0.0**, while RustyNES's own **v2.0.0 "Timebase"** (2026-07-03) is the later release that *replaced* that same scheduler. The current release is **v2.5.8**.
> RustyNES's emulation core descends from an extensively-documented accuracy program. Where this and related docs reference deep "v1.x"/"v2.x" engine narrative, read it as upstream engine lineage (engineering history), not as RustyNES release versions. Two distinct "v2.0"s exist and must not be conflated: the engine-lineage v2.0 master-clock work shipped as RustyNES **v1.0.0**, while RustyNES's own **v2.0.0 "Timebase"** (2026-07-03) is the later release that *replaced* that same scheduler. The current release is **v2.5.9**.

---

Expand Down
Loading