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
57 changes: 57 additions & 0 deletions .github/release-notes/v2.5.0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# RustyNES v2.5.0 "Rungwork"

**The 6502 rung, and the two gates it cannot reach.**

Rungwork is the scaffolding you stand on to build the next thing — and the honest name for this release, because the rung it was scoped to close does not fully close, for reasons that are structural rather than defects.

What is real: **nine opcode-group ROMs at 2115 records**, **4537 cycles of per-cycle bus equality**, **27,388 cycles of nestest**, and **`pc` agreeing on 100% of cycles**. What is not: nestest 0-diff over the whole run, the 5 M-cycle window, and the interrupt-injection sweep. All three are named below with why.

Written from public documentation only. No reference NES core was opened; none is present in the tree.

## Interrupts, and the parts that actually differ

`nmi_n` and `irq_n`, both active low, with the distinction that matters: **/NMI is edge-sensitive and latches** — once seen it stays pending until serviced, even if the line releases — while **/IRQ is level-sensitive** and simply sampled. Implementing NMI as a level is the classic error, and it is invisible on any test whose NMI stays asserted.

**Delayed-`I` falls out of where the poll sits**, not from a special case. The poll is at the instruction boundary and reads `P` as it stands *before* the retiring instruction changes it, so `CLI` followed by an asserted /IRQ does not take the interrupt until one instruction later.

**The hijack is decided at the push, not at entry.** An NMI arriving before the vector read steals it, so a `BRK` can end up running the NMI handler having already pushed a `P` with B set. Deciding at entry makes the hijack impossible and nothing else changes — which is exactly why it needs its own test.

Reset does **not** latch a pending NMI from an already-low line: there was no transition, and treating the initial level as an edge fires a spurious interrupt on the first instruction of every program that ties it low.

## `pc` now agrees on 100% of cycles

It was compared on nothing, because the two sides did not mean the same thing by it — the oracle holds the *instruction's* opcode-fetch PC across every cycle of that instruction, the DUT exposed its live register, and they agreed on **45%**: high enough to look nearly right, far too low to gate on.

The fix was to give the wrapper the oracle's definition, not to lower the bar. **Two corrections were needed and the first alone was not enough**: latching at the opcode fetch reached 54% and left the DUT lagging by exactly one instruction. That residual's *shape* is the diagnosis — a uniform one-record shift is a sampling phase error, never an arithmetic one, because the latch updates on the same edge as the fetch. Reporting the live PC *during* the fetch cycle and the latch everywhere else closes it: **3551 of 3551 cycles**.

It is latched in the wrapper, never in `cpu6502`. Same ADR 0037 argument as the rest of that file: it is a testbench view, not a register the hardware needs, and putting it in the synthesisable module is the comfortable mistake that erodes with no symptom.

## Five defects, all found by the bus gate

None was visible to rung 1, which compares seven registers at instruction boundaries.

**`RTS` read the incremented address** on its final cycle; hardware reads the pulled address and increments at that cycle's end. **`AM_IZX`'s `default` arm caught cycle 1** — the operand fetch — so the core drove an effective address before the pointer byte had been read. **`AM_IZY` tested `idx_page_cross` instead of `izy_cross`**, a quantity with no meaning for a mode that adds Y to the *fetched* pointer; the first crossing case took five cycles where hardware takes six. **`AM_IZY` wrote at the unfixed address** when the index did not carry.

And **`build()` stamped over every ROM's interrupt vectors**, so `opgroup8`'s handlers were unreachable: the ROM assembled, every vector read `$C000`, and `BRK` "worked" by falling through to the program's first instruction. **Both sides would have agreed on that**, because both read the same wrong ROM. The fill byte is `NOP`, not an impossible value, so "was this written?" could not be answered by inspecting the byte — the assembler now returns the set of offsets the program wrote.

## The two gates that do not close, and why that is written down

**nestest stops at a peripheral.** It reads `$2002` at cycle 27396. The oracle has a PPU and returns `$80` with vblank set; this rung's testbench is flat 64 KiB memory. **Both sides address `$2002`** — the divergence is the *data*, so it is a missing peripheral and it is rung 3 by design. The 5 M-cycle window hits the same wall much sooner. The gate is therefore bounded at 27396 and stated as a number in the Makefile, because a gate whose extent moves silently is not a gate.

**The interrupt-injection sweep has no oracle.** It needs NMI and IRQ asserted at chosen cycles on *both* sides, and `rustynes-core` exposes no injection API — its /IRQ comes from the APU frame counter or a mapper, its /NMI from the PPU, none of which exist at this rung. So the pins, the edge-versus-level distinction, the hijack and delayed-`I` are **implemented and not oracle-verified**. `BRK` *is* verified, because a software interrupt needs no pin and exercises the same seven-cycle sequence, the same three pushes, the same vector fetch and the same `I`-set-after-push.

Saying this rather than shipping the number is the point. "The 6502 rung closes" was this release's own scope, and a rung reported closed on a gate it never ran is the exact failure this programme was built to catch — the same shape as a skipped CI job satisfying a required check, or a mutation harness measuring against its own mutant.

## ADR 0038, and a contract amended in both places it is stated

[ADR 0038](https://github.com/doublegate/RustyNES/blob/main/docs/adr/0038-cosim-interrupt-injection-api.md) records the decision to allow a **test-only, default-off** interrupt-injection API on the core, with six constraints. Two are preconditions of merging rather than follow-ups: **zero hot-path cost asserted by measurement**, and **byte-identical output verified with the feature absent *and* present-but-unused** — a distinct case, because `irq-timing-trace` selects a different per-dot loop merely by being compiled in, and that one reached the accuracy battery itself. If either fails, the decision is **void**, not "revisit", and the fallback is written down.

ADR 0037's hard contract and the Fabric plan both said "the emulation core is untouched", and both now point at 0038. Amending one and leaving the other is how an original claim survives its own revision. The honest restatement is "untouched in the default build, with one default-off test feature" — weaker than what eight releases of notes have said, and recorded as a cost rather than buried.

## No upstream sync

The cadence rule is amended: the next libretro/RetroArch sync waits for the **MiSTer core to be complete**, at whatever `vX.Y.0` that lands on — not the next one on the calendar. A lagging `display_version` understates what shipped; a leading one overstates it, and the second is worse.

## Verification

No crate under `rustynes-{cpu,ppu,apu,mappers,core}` changes, so **AccuracyCoin 141/141 (100.00%, RAM decoder) and nestest 0-diff hold by construction.**
6 changes: 3 additions & 3 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.4.9 (the scheduling model is v2.0.0 "Timebase" onward)
**Applies to:** RustyNES v2.5.0 (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
49 changes: 49 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,55 @@ cycle-accurate core later replaced.

## [Unreleased]

## [2.5.0] - 2026-08-23 - "Rungwork" (the 6502 rung, and the two gates it cannot reach)

Comment thread
coderabbitai[bot] marked this conversation as resolved.
### Added

- **Interrupts in the DUT** (`RustyNES_MiSTer@27171cd`): `nmi_n` and `irq_n`, the
/NMI **edge latch** and level-sampled /IRQ, `BRK`, the three vectors, `RTI`, the
**NMI/`BRK` hijack** decided at the push rather than at entry, and **delayed-`I`**
falling out of where the poll sits rather than needing a special case.
- **The indirect addressing modes** — `(zp,X)` and `(zp),Y` across seven operation
groups. The last documented addressing gap.
- **`pc` compared, and agreeing on 100% of cycles** — 3551/3551. The wrapper now
derives an instruction-scoped PC matching the oracle's definition.
- **nestest as a bounded gate** — **27,388 cycles**, 8571 instructions, no
unimplemented opcode, matching on `pc`, `bus_addr`, `bus_data`, `bus_access`.
- **[ADR 0038](docs/adr/0038-cosim-interrupt-injection-api.md)** — a test-only,
default-off interrupt-injection API, with six constraints and a written
fallback if two of them fail.

### Fixed

- **`RTS` read the incremented address** on its final cycle; hardware reads the
pulled address and increments at that cycle's end.
- **`AM_IZX`'s `default` arm caught cycle 1**, driving an effective address before
the pointer byte had been read.
- **`AM_IZY` tested the wrong page-cross signal** (`idx_page_cross` rather than
`izy_cross`), taking five cycles where hardware takes six.
- **`AM_IZY` wrote at the unfixed address** when the index did not carry.
- **`build()` stamped over every ROM's interrupt vectors**, so `opgroup8`'s
handlers were unreachable — and *both sides would have agreed on the same wrong
ROM*.
- **`cpu-gate` and `cpu-bus-gate` ran the DUT with different memory.** `RAM_INIT`
is now required by both.

### Notes

- **Two of this release's own stated gates are structurally blocked, and neither
is a defect.** nestest 0-diff and the 5 M-cycle bus window both need a PPU —
nestest's first `$2002` read is where it stops, with *both sides addressing
`$2002`* and only the data differing. That is rung 3 by design.
- **The interrupt-injection sweep has no oracle.** `rustynes-core` exposes no
injection API; its /IRQ comes from the APU or a mapper and its /NMI from the
PPU. So the pins, hijack and delayed-`I` are **implemented and not
oracle-verified**; `BRK` *is* verified, since a software interrupt needs no pin.
ADR 0038 records the decision and its conditions.
- **No upstream libretro sync.** The cadence rule is amended: the next sync waits
for the MiSTer core to be **complete**, not for the next `vX.Y.0`.
- No crate under `rustynes-{cpu,ppu,apu,mappers,core}` changes, so **AccuracyCoin
141/141 (100.00%, RAM decoder) and nestest 0-diff hold by construction.**

## [2.4.9] - 2026-08-23 - "Plumbline II" (the bus half of rung 2, and what it found the day it existed)

### 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.4.9"
version = "2.5.0"
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-23
**Applies to:** RustyNES v2.4.9
**Applies to:** RustyNES v2.5.0

---

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.4.9 "Plumbline II"**. 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.0 "Rungwork"**. 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.4.9**.
> 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.0**.

---

Expand Down
Loading