Phosphoric › Articles › Phosphoric 2.0
Phosphoric 2.0: a cycle-stepped machine — and the claim we withdraw
What we had said and why it was wrong, what V2 changed, what changes visibly — and what we still do not claim.
Up to 1.120.0-alpha, Phosphoric presented itself as “cycle-accurate”. That was not true in the sense reference emulators give the word. This article says what was withdrawn, what was built instead, and where the claim stops. Test and option names are those of the repository.
1. What we had said, and why it was wrong
The 6502 core executed an instruction as a block: bus accesses came out in the right order and the cycle total per opcode was right, but internal cycles were caught up by padding at the end of the instruction, the NMOS dummy accesses did not exist, and interrupts were taken at instruction boundaries. The VIA received batches of cycles, the ULA rendered a whole line at once, the PSG ran at the audio sample rate.
That is an honourable level — we named it N2, “bus-cycle ordered” — but it is not
“cycle-exact”. We withdrew the wording, wrote a verifiable scale (docs/ACCURACY.md: N1 to N4,
each level backed by the test that proves it), and an automatic guard (make test-docs-claims)
that rejects any unqualified “cycle-accurate” in showcase documents. Then we did V2.
| Level | Name | Operational definition |
|---|---|---|
| N1 | Instruction-counted | The cycle total per opcode is exact; peripherals advance in batches after the instruction. |
| N2 | Bus-cycle ordered | Each real bus access lands on the right intra-instruction cycle; internal cycles are caught up at the end of the instruction; IRQ at instruction boundaries. |
| N3 | Cycle-stepped | The machine's unit of advance is the cycle: one bus action per cycle (dummy accesses included), peripherals in lockstep; IRQ/NMI on the penultimate cycle. |
| N4 | Sub-cycle (φ1/φ2) | The cycle is subdivided; setup/hold races between boards and bus are modelled. |
2. What V2 changed
An oracle first
Before touching the core, make test-cycle replays the SingleStepTests/65x02 vectors — 10,000
cases per opcode, with the expected bus trace cycle by cycle — and scores four properties separately (final
state, cycle total, bus subsequence, exact bus sequence). The measured, published starting point:
44.26 % exact bus sequences. The oracle also exposed five logic defects in the core, fixed
before even starting.
A micro-sequenced core
Each instruction becomes a plan of micro-operations, one per cycle, each performing exactly its bus access —
including the dummy ones (zero-page indexing, page crossing, RMW write-back, dead stack reads). Result:
100.00 % over 2,440,000 cases. Interrupts are sampled on the penultimate
cycle, which gives for free the delayed I flag of CLI/SEI/PLP
and the hijacking of a BRK by an NMI. Both cores share the same computation (flags, BCD, illegal
opcodes): only the sequencing differs. The old one remains available (--cpu-legacy).
A master clock
emu_cycle() advances the whole machine by one cycle, in a fixed order: the CPU
performs its bus access, peripherals advance by one cycle — never a batch —, then the ULA fetches the cell of
that same cycle. That is the order measured on the hardware by Mike Brown; until 2.0.1 the ULA went first, and
every split landed one cell too far right. The main loop no longer computes anything: it asks.
The components, one by one
- VIA 6522: counts per cycle; Timer 1 regained its N+2 period (the old model gave N: 20 % error for N=10).
- ULA: fetches one 6-pixel cell per cycle, at the instant the beam reads it — a mid-line write only reaches cells not yet scanned; raster splits become possible.
- AY-3-8910 PSG: runs at clock/8 (125 kHz) with an integrated output — a tone programmed above Nyquist attenuates instead of aliasing; the envelope, twice too slow since forever, runs at the right speed; the analogue output stage was taken from the official schematic.
- WD1793: reports
LOST DATAand write protection. - Signal-level cassette: odd frame parity, not even.
3. What changes visibly
- Raster splits: a program changing ink or mode mid-line gets what the hardware would give, not a uniform line.
- Sound: envelopes of the right duration, no more phantom whistle on very high tones, a centred signal (DC blocked as on the board).
- IRQs: an interrupt can no longer be taken before the current instruction;
SEIdoes not protect the following instruction from an already pending IRQ. - Destructive-read registers: a BASIC
POKEto an ACIA data register loses a byte, becauseSTA (zp),Yperforms a dummy read before writing — on the emulator as on the machine. Phosphoric was more permissive than the hardware; it no longer is. - Savestates: a state taken mid-frame resumes exactly where it stopped (beam position, timers, interrupt sample).
4. What we learnt on the way
Two defects would never have been seen without changing method.
The PSG envelope was declared conformant “by recalculation” — a recalculation assuming 16 states instead of 32. A false assumption is invisible on re-reading; it only shows when measuring the signal. Audio tests now measure frequencies and durations instead of comparing bytes.
The second is even more instructive. An untaken branch made its decision one cycle too late, in a micro-op with no bus access. The CPU counter stayed right, so the oracle was 100 % green. But the master clock had been called one more time: the ULA advanced by a cycle that neither the CPU nor the VIA had lived — about 410 times per frame on the BASIC ROM, i.e. one frame of drift per second between the picture and the rest of the machine. An oracle that only looks at the CPU does not prove the machine's synchronisation. What exposed it: a savestate determinism test, which required raster breakpoints to land on exactly the same cycle.
5. What we do not claim
“Phosphoric is cycle-exact” — no. The FDC is still timed by fixed delays on a flat image (no MFM stream, hence no real byte loss nor CRC). The horizontal reference, on the other hand, is no longer a convention: the (measured) ULA counter puts column 0 at count 0 — but the absolute phase between that counter and the CPU is only observable on a real ORIC with the “VSYNC hack”, which we do not emulate. The VIA one-shot half-cycle is not represented. Default cassette loading remains the ROM patch, by choice: same content loaded, 2.4× fewer cycles.
The exact authorised wording, and the test that would falsify each of its lines, are in
docs/ACCURACY.md.
6. Cost
Going cycle-stepped cost, on the reference machine at full speed, 491 → 611 µs per emulated frame:
3 % of the 20 ms budget. make test-bench now rejects any overrun beyond 5 %.
7. Since 2.0.0
- 2.0.1 — WebAssembly build repaired (broken since the macOS port), online page rebuilt in 2.0.
- 2.0.2 — intra-cycle CPU/ULA order fixed from Mike Brown's Unofficial ULA Guide: the 6502 accesses first, the ULA then fetches the same count; every raster split landed one cell (6 pixels) too far right. The “uncalibrated horizontal reference” caveat is lifted.
- 2.0.3 — main loop split into named stages (1,296 → 126 lines), no visible change.
- 2.0.4 — LOCI co-simulation: the F8 button has a single owner (the co-simulated firmware); long press = Mike Brown's diagnostic ROM.
Test suite: 1,208 tests in 60 suites, 100 % green; automatic release on tag (Linux binary, Windows zip); browser version online.
Sources & links
- Phosphoric repository: Codeberg · Framagit · GitHub
- Accuracy scale and per-component status: docs/ACCURACY.md
- Original technical note (French): docs/articles/v2-cycle-stepped.md
- 6502 oracle: SingleStepTests/65x02; Klaus Dormann's functional test.
- Mike Brown, ORIC 1/ATMOS Unofficial ULA Guide: oric.free.fr · oric.signal11.org.uk
- Try it online: WebAssembly version