The 6502 core
Phosphoric’s 6502 no longer thinks in instructions but in cycles: each instruction is a plan of micro-operations, one per cycle, each with its bus access. Here is how, and how we know.
On this page
What has to be reproduced
The Oric’s NMOS 6502 makes one bus access on every cycle, without exception. When an instruction has nothing useful to read or write during a cycle, the processor makes an access anyway: a dummy access. It re-reads the next opcode, reads an address before indexing it, writes a value back before modifying it (for read-modify-write instructions), reads the stack without using it.
These accesses are invisible to RAM. But on an input/output register they matter: reading an ACIA’s data register consumes the received byte, reading some VIA registers clears an interrupt flag.
A plan of micro-operations
Phosphoric’s core (src/cpu/) describes each instruction as a sequence of micro-operations, one
per cycle, each making exactly its bus access: including the dummy accesses of zero-page indexing,
page crossing, the write-back of RMW instructions, and dead stack reads.
- 256 opcodes out of 256: the 151 official ones and the 105 NMOS “illegal” ones.
- 13 addressing modes, the NMOS decimal mode, level-sensitive IRQ.
- Accepted divergence: the 12 JAM opcodes stop the processor instead of locking the bus.
Interrupts on the penultimate cycle
The 6502 samples its interrupt lines during the second-to-last cycle of an instruction. Phosphoric does the same, which gives several behaviours of the real processor for free:
- an interrupt cannot be taken before the current instruction;
- a line that becomes active during the last cycle of an instruction is seen too late: it is only honoured after the next instruction;
SEIdoes not protect the following instruction from an IRQ already pending, andCLIorPLPsymmetrically delay its arrival by one instruction;- an NMI that arrives during a
BRKhijacks it to its own vector.
The oracle: how we know
make test-cycle replays the SingleStepTests/65x02 vectors: 10,000 cases per opcode, each with
the initial state, the final state and the expected bus trace, cycle by cycle. It scores four
properties separately, from weakest to strongest:
| Property | What it proves | Level |
|---|---|---|
| final state (registers and RAM) | the computation is right | independent of timing |
| total cycles per instruction | the counters are exact | N1 |
| bus subsequence | no stray access, no inversion | N2 |
| exact bus sequence | the right access on every cycle | N3 |
The vectors, large (about 1 GB) and third-party, are not in the repository: tools/fetch_vectors.sh
downloads them. Without them, the target skips cleanly, and so stays in make tests and in the CI.
From 44.26% to 100%
old core (--cpu-legacy) | micro-sequenced core (default) | |
|---|---|---|
| exact bus sequence | 44.26% | 100.00% |
| cycles without an address | 12.8% of boot cycles | 0 |
| NMOS dummy accesses | absent | all issued |
| interrupt taking | at the instruction boundary | on the penultimate cycle |
delayed I flag (CLI, SEI, PLP) | no | yes |
NMI hijacking a BRK | no | yes |
| cost per frame (20 ms budget) | 1.9% | 2.5% |
The 44.26% figure was measured on 10 September 2026 on an exhaustive run: 244 opcodes (excluding the 12 JAM) × 10,000 cases = 2,440,000 cases, in 6.4 seconds. It is the published starting point of version 2.0. From the next step (V2-S2), the micro-sequenced core reached 100.00% on all four properties. A floor in the test forbids going back down.
The functional test
The 6502’s reference functional test, 6502_functional_test, passes in full: it reaches its success
trap at $3469 after about 96 million emulated cycles. It already confirmed that the old core’s
shortfall was in timing, not logic.
Two cores, one computation
Both engines share the same computation functions: flags, decimal, illegal opcodes. Only the
scheduling of cycles differs. The old one remains available through --cpu-legacy; it cannot stop
between two cycles, and so also forces the ULA to render line by line.
The switch to the new core was checked for identity: Oric-1 and Atmos start-ups byte-identical, thirteen real programs identical on screen, and the functional test passed in the same number of cycles (96,241,367) on both cores.
What it changes for programs
Faithfulness has a price for over-confident programs. BASIC’s POKE writes with STA (zp),Y, which
makes a dummy read before writing. A POKE 1021,C to an ACIA’s data register therefore eats the byte
that had just arrived. An echo program written in BASIC loses bytes, on Phosphoric as on a real
machine. The bus trace shows it in two lines:
R $03FD $52 <- the POKE's dummy read: the byte "R" is eaten
W $03FD $4F <- the intended write
A serial driver is therefore written in assembly, with absolute STA or STY, which have no
dummy cycle. Before 2.0, Phosphoric let these programs through: it was more lenient than the hardware.
The traces
--trace FILE: one line per instruction, disassembled, with the registers.--cycle-trace FILE: one line per cycle, in the formatCYCLE T ADDR DATA PC A X Y SP P FLAGS IRQ, whereTisR(read),W(write) ori(internal cycle). It is used to compare Phosphoric, line by line, with another emulator or with instrumented hardware.ilines only appear with--cpu-legacy: the default core makes a real access on every cycle.--profile FILE: hit counts and cycles per address, an opcode histogram, the 20 hot spots.