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
73 changes: 73 additions & 0 deletions .github/release-notes/v2.4.5.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
## v2.4.5 "Compass" — the core reaches memory, and chooses

Three addressing modes, the load and store groups, and all eight branches. A compass does not tell you where you are; it tells you which way to go, which is what a branch is — and the release is also where the core first *reaches* memory rather than only shuffling registers.

**The emulation core is untouched.** No behaviour change to `rustynes-{cpu,ppu,apu,mappers,core}`. AccuracyCoin remains **141/141 (100.00%, RAM decoder)** and nestest 0-diff. The RTL lives in the sibling repository, pinned at **`RustyNES_MiSTer@b01a656`**.

---

### What matches

| ROM | records | covers |
|---|---|---|
| `opgroup1` | **147/147** | v2.4.4's reset and implied group — unchanged by the rewrite |
| `opgroup2` | **140/140** | every construct new in this release |

287 records, compared on `cycle`, `pc`, `a`, `x`, `y`, `p`, `s`.

Immediate, zero page and absolute addressing; `LDA`/`LDX`/`LDY`, `STA`/`STX`/`STY`; `BPL`/`BMI`/`BVC`/`BVS`/`BCC`/`BCS`/`BNE`/`BEQ`.

**The ordering is not arbitrary.** Loads are what finally let a test program put an *arbitrary* value in a register — until now `A`, `X` and `Y` could only hold what power-on and increments reached from zero. Stores are the first writes this core has ever performed, so a read-back is what proves the write landed. And branches are the first opcodes to **read** a flag, which retires the dated lint waiver `p` has carried since v2.4.4. That is the outcome a dated waiver exists for: had the branches slipped, the lint would have gone on asking why.

### The page-crossing branch is placed, not stumbled into

An `ORG` directive exists in the ROM assembler for exactly one test. At `$C0FC` the next instruction is `$C0FE`, and `+$10` lands at `$C10E` — a different page, so the taken branch costs **four** cycles instead of three.

An implementation that fixes the high byte without spending the cycle agrees on every register and disagrees only on `cycle`. That is why the trace compares it, and why the fix-up is written as an explicit carry/borrow rather than a 16-bit add: the hardware genuinely computes the low byte first and only then discovers the high byte was wrong, and **that discovery is the extra cycle**.

---

### Three things that cost real time

**Write intent was sampled after the clock edge.** `we` and `dout` are combinational functions of `state`/`tcyc`, and those change **on** the posedge — so reading them afterwards reads the *next* cycle's plan. Every store silently did nothing, and the symptom surfaced at a later `LDA`, several instructions after the cycle that was actually wrong.

**Never read RAM a test program has not written.** The oracle powers on with deterministic **seeded** work RAM; a flat-memory testbench starts at zero. A read of an untouched address diverges for a reason that has nothing to do with the CPU. Observed as the oracle returning `$36` from `$0003` where the harness returned `$00` — and the first reading of that was "the absolute store is broken". Test ROMs now write a sentinel before reading.

**A read-back in the same addressing mode tests round-tripping, not addressing.** `STA $10` followed by `LDA $10` is self-consistent under any address mutation: send both to `$0110` and it still passes. Two mutants — zero page to the wrong page, and absolute with its address bytes swapped — both came back **NOT CAUGHT**. Each mode is now cross-checked by a *different* mode, and the ROM says so at the site.

That is the third time in two releases that a mutation exposed a test which read correctly and verified nothing. It is not becoming less useful.

### Mutations

| mutation | result |
|---|---|
| baseline / restored | MATCH |
| branch page fix-up condition inverted | **DIVERGE** |
| branch sense inverted | **DIVERGE** |
| absolute address bytes swapped | **DIVERGE** |
| `LDA` does not set N/Z | **DIVERGE** |
| zero-page store to the wrong page | **DIVERGE** |
| branch flag select off by one | **DIVERGE** |
| absolute takes 3 cycles not 4 | **DIVERGE** |

One had to be rewritten to be valid at all: forcing `br_fixup = 1'b0` left `br_sum[8]` and `adl[7]` unused, so Verilator refused to build it. **A mutant that does not compile is not a catch** — inverting the condition keeps both signals live and tests the same property.

### Structural changes

**One decoder, called twice.** v2.4.4 had two duplicated `case` blocks — over `din` at fetch and over `ir` during execution — which had to agree by hand across seventeen arms. They did, and would not have kept doing so: a mode added to one and not the other is an instruction that decodes differently depending on when you look. A function returning a packed struct removes the possibility rather than documenting it.

At fetch time only the **operation** is consulted, not the whole struct. Taking all of it left `am` and `writes` dead, which Verilator flagged and which is correct: the addressing mode of a byte not yet latched into `IR` is not something this design has any business reading.

The branch condition is expressed as the 6502's own encoding — opcode bits `7:6` select the flag, bit `5` the sense — rather than eight arms, because eight arms is eight chances to invert one.

### Provenance

Written from public documentation only — the NESdev wiki's 6502 cycle-times, instruction and addressing-mode pages, and this repository's own `docs/cpu-6502.md`. No reference NES core was opened; none is present in either tree, and CI asserts their absence.

### Next

v2.4.6: indexed addressing (`zp,X`, `abs,X`, `abs,Y`) with its own page-cross penalty, the compare group, and `ADC`/`SBC` — the first opcodes to *consume* carry rather than only set it.

### Compatibility

Documentation and programme-record only on the RustyNES side. No save-state, movie, netplay or public-API change in any shipped or default-build package; `.rns` and `.rnm` are unchanged.
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.4.4 (the scheduling model is v2.0.0 "Timebase" onward)
**Applies to:** RustyNES v2.4.5 (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
75 changes: 75 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,81 @@ cycle-accurate core later replaced.

## [Unreleased]

## [2.4.5] - 2026-08-22 - "Compass" (the core reaches memory, and chooses)

### Added

- **Three addressing modes, the load/store group, and all eight branches.**
(v2.4.5, continuing the "Fabric" line. RTL pinned at
`RustyNES_MiSTer@b01a656`.) Immediate, zero page and absolute;
`LDA`/`LDX`/`LDY` and `STA`/`STX`/`STY` across them; `BPL`/`BMI`/`BVC`/`BVS`/
`BCC`/`BCS`/`BNE`/`BEQ`. **The emulation core is untouched.**

Two ROMs, **287 records**, matching the oracle on all seven CPU fields:

| ROM | records | what it covers |
|---|---|---|
| `opgroup1` | 147/147 | v2.4.4's reset and implied group — unchanged by the rewrite |
| `opgroup2` | 140/140 | every construct new in this release |

The ordering is not arbitrary. **Loads are what finally let a test program put
an arbitrary value in a register** — until now `A`, `X` and `Y` could only hold
what power-on and increments reached from zero. **Stores are the first writes
this core has ever performed.** And **branches are the first opcodes to read a
flag**, which retires the dated lint waiver `p` carried since v2.4.4 — the
outcome a dated waiver exists for.

- **A page-crossing branch, placed deliberately rather than stumbled into.** An
`ORG` directive exists in the ROM assembler for exactly one test: at `$C0FC`
the next instruction is `$C0FE`, and `+$10` lands at `$C10E` — a different
page, so the taken branch costs **four** cycles instead of three. An
implementation that fixes the high byte without spending the cycle agrees on
every register and disagrees only on `cycle`, which is why the trace compares
it.

### Fixed

- **Write intent was sampled after the clock edge.** `we` and `dout` are
combinational functions of `state`/`tcyc`, and those change **on** the posedge
— so reading them afterwards reads the *next* cycle's plan. Every store
silently did nothing, and the symptom surfaced at a later `LDA`, several
instructions after the cycle that was actually wrong.

### Notes

- **Never read RAM a test program has not written.** The oracle powers on with
deterministic **seeded** work RAM; a flat-memory testbench starts at zero. A
read of an untouched address therefore diverges for a reason that has nothing
to do with the CPU — observed as the oracle returning `$36` from `$0003` where
the harness returned `$00`, which read at first as "the absolute store is
broken". Test ROMs now write a sentinel before reading.

- **A read-back in the same addressing mode tests round-tripping, not
addressing.** `STA $10` followed by `LDA $10` is self-consistent under any
address mutation: send both to `$0110` and it still passes. Two mutants — zero
page to the wrong page, and absolute with its address bytes swapped — came
back **NOT CAUGHT**. Each mode is now cross-checked by a *different* mode, and
the ROM says so at the site.

That is the third time in two releases a mutation exposed a test that read
correctly and verified nothing.

- **A mutant that does not compile is not a catch.** Forcing `br_fixup = 1'b0`
left `br_sum[8]` and `adl[7]` unused, so Verilator refused to build it and the
run reported a build failure where a naive harness would have counted a catch.
Inverting the condition keeps both signals live and tests the same property.

- **One decoder, called twice.** v2.4.4 had two duplicated `case` blocks — over
`din` at fetch and over `ir` during execution — which had to agree by hand
across seventeen arms. They did, and would not have kept doing so: a mode added
to one and not the other is an instruction that decodes differently depending
on when you look. A function returning a packed struct removes the possibility
rather than documenting it.

- The branch condition is expressed as the 6502's own encoding — opcode bits
`7:6` select the flag, bit `5` the sense — rather than eight arms, because
eight arms is eight chances to invert one.

## [2.4.4] - 2026-08-22 - "Ignition" (the first RTL, and the reset sequence that corrected our own spec)

### 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.4"
version = "2.4.5"
edition = "2024"
rust-version = "1.96"
license = "GPL-3.0-or-later"
Expand Down
12 changes: 7 additions & 5 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-20
**Applies to:** RustyNES v2.4.4
**Applies to:** RustyNES v2.4.5
Comment thread
coderabbitai[bot] marked this conversation as resolved.

---

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.4 "Ignition"**, which also carries the never-tagged v2.4.0 "Concordance".
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.5 "Compass"**. 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.4**.
> 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.5**.

---

Expand Down Expand Up @@ -68,7 +68,9 @@ A one-directional crate graph keeps each chip (`rustynes-cpu`, `rustynes-ppu`, `

## Emulation Approach

RustyNES uses **cycle-accurate, dot-level** emulation rather than scanline-based shortcuts. The scheduler advances one PPU dot at a time; the CPU advances on the appropriate dot for the region; the APU advances every other CPU cycle. The Bus owns all mutable device state, and the CPU borrows it during `tick()` — the architectural choice (per the TetaNES postmortem) that avoids the borrow-checker fight a split bus creates. See [`ARCHITECTURE.md`](ARCHITECTURE.md) and [`docs/scheduler.md`](docs/scheduler.md).
RustyNES uses **cycle-accurate** emulation rather than scanline-based shortcuts. Since **v2.0.0 "Timebase"** the scheduler is a single canonical cycle counter in which every CPU cycle is a real bus access, with a split-around-the-access `start_cycle`/`end_cycle` PPU catch-up (ADR 0002 / ADR 0029). The APU advances every other CPU cycle.

> This paragraph described the **retired** five-counter dot-lockstep model — "the scheduler advances one PPU dot at a time" — which v2.0.0 replaced outright and which is no longer a path in the code. Corrected in v2.4.5, found by review rather than by a gate: release anchors are pinned by `release_anchor_audit`, and ordinary architecture prose is not. The Bus owns all mutable device state, and the CPU borrows it during `tick()` — the architectural choice (per the TetaNES postmortem) that avoids the borrow-checker fight a split bus creates. See [`ARCHITECTURE.md`](ARCHITECTURE.md) and [`docs/scheduler.md`](docs/scheduler.md).

---

Expand All @@ -77,7 +79,7 @@ RustyNES uses **cycle-accurate, dot-level** emulation rather than scanline-based
1. **Emulation enthusiasts** — reference-grade accuracy with a modern, themeable desktop UX and an in-app debugger.
2. **The TAS community** — frame-perfect deterministic `.rnm` movie record / playback / branching built directly on the determinism contract.
3. **Netplay users** — GGPO-style rollback netplay (2–4 players), native (UDP) and in the browser (WebRTC).
4. **Homebrew developers** — broad mapper coverage (51 families), FDS, an instruction/PPU/memory debugger, and an embeddable `no_std` core.
4. **Homebrew developers** — broad mapper coverage (174 families), FDS, an instruction/PPU/memory debugger, and an embeddable `no_std` core.
5. **Rust developers** — a clean, modular workspace and a reusable 6502 CPU crate.

---
Expand Down
Loading