Quick start | Conformance | Hardware quirks | Undefined state | Issues
256 opcodes · 66 mnemonics · 66 addressing forms · 256,000 conformance cases, 0 failures · 219 tests · 100% statement and branch coverage
from spc700 import Cpu, SparseMemory
memory = SparseMemory()
memory.write8(0x0200, 0xE8)
memory.write8(0x0201, 0x42)
cpu = Cpu(memory, model="spc700", reset=False)
cpu.pc = 0x0200
cpu.step()
cpu.a
# 0x42The SPC700 looks like a 6502 with a couple of extra registers, and that resemblance is the trap. Several of its instructions behave in ways the family would not lead you to expect, and each one is a place where a reasonable implementation quietly diverges from the hardware.
The division does not compute a quotient once the answer stops fitting. The decimal adjust reads an accumulator it has already modified halfway through. The half carry means the opposite thing after a subtraction than it does after an addition. None of those is exotic; all of them are reachable from ordinary audio driver code, and all of them are easy to get subtly wrong in a way no smoke test catches.
Every one of the 256 opcodes is implemented, and correctness is measured rather than asserted. The core is checked against SingleStepTests, which carries 1,000 cases for each opcode with a full before and after state. All 256,000 pass.
And nothing starts clean. Memory is filled with a reproducible scrambled pattern unless a caller asks for something else in writing, and a reset sets only what the hardware itself defines.
|
The SPC700 leaves none of its 256 opcodes undefined, so there is no illegal instruction to decide about and no excuse for a gap. |
The division past its useful range, both decimal adjusts, and the inverted half carry are written the way the silicon does them. |
|
|
One table drives both execution and listing, so a new opcode cannot be added to one and forgotten in the other. |
|
The suite commit is pinned so a build is reproducible. A weekly job runs against whatever upstream holds now and opens a pull request or an issue. |
Pure Python, standard library only. The release tooling is the sole |
| Tool | Version | Install |
|---|---|---|
| Python | >= 3.12 | python.org |
git clone https://github.com/gufranco/sony-spc700-python.git
cd sony-spc700-pythonpython3 spc700/core.test.py
# Ran 105 tests in 0.03s
# OKThese are the four places an implementation written from a summary of the instruction set will disagree with a console.
cpu.y, cpu.a, cpu.x = 0x00, 0x0A, 0x03
cpu.step()
# a = 3, y = 1, an ordinary quotient and remainderOnce the quotient no longer fits, the hardware does not fail and does not saturate. It runs the same shift and subtract network past the end of its useful range and leaves behind whatever falls out. The overflow flag reports that the result is not a quotient, and the half carry reports a nibble comparison that has nothing to do with the division at all. Dividing by zero is not a special case either: it takes the same path and produces a defined value.
cpu.a, cpu.c = 0x9A, False
cpu.step()
# a = 0x00, c = TrueDAA tests the accumulator twice. The second test looks at the value the first branch may already have changed, so a carry produced by adding sixty feeds the nibble test below it. Testing the original value instead is the obvious reading, and the wrong one.
After ADC the half carry is set when a carry crossed out of the low nibble. After SBC it is set when one did not cross. Carrying the addition rule into the subtraction gives an answer that is right about half the time, which is the worst possible failure mode.
The P flag decides whether the direct page sits at $0000 or $0100, so the same instruction byte reaches two different addresses depending on a flag set somewhere else entirely. A word read inside that page wraps within the page rather than carrying into the next one.
from spc700 import Cpu, Memory, SparseMemory
SparseMemory().read8(0x1234)
# some byte derived from the address; the same byte every time; not zero
Memory(size=0x1000).data == bytearray(0x1000)
# False
Memory(size=0x1000, fill=0).data == bytearray(0x1000)
# True, because a caller asked for it in writing
cpu = Cpu(Memory(fill=0))
cpu.a, cpu.x, cpu.y, cpu.sp
# whatever a reset leaves behind, reproducible from the seed, not zeroAudio RAM is not cleared at power on. It holds whatever pattern the parts settle into, and a driver that reads a byte before writing it is reading that pattern. Memory that begins at zero makes such a read look deliberate and stable, which is exactly how that class of bug survives a test suite and fails on hardware.
python3 conformance/fetch.py ~/.cache/conformance-suites
python3 conformance/singlestep.py ~/.cache/conformance-suites/spc700/spc700/v1
# 256 files from ~/.cache/conformance-suites/spc700/spc700/v1
# 256000 agreed, 0 did notThe suite is several gigabytes, so conformance/fetch.py takes a partial clone that skips blob history and a sparse checkout of only the directories conformance/suites.json names.
Each case gives a full initial state, the bytes in memory, and the state one instruction later. conformance/singlestep.py builds exactly that machine, steps once, and compares every register, the status register and every named byte. Memory outside the named bytes is scrambled rather than cleared, because the suite says nothing about those addresses.
The suite comes from JSMoo by way of SingleStepTests, and its generator is published alongside it, so more cases can be produced than the 1,000 per opcode that ship.
| When | What runs | On disagreement |
|---|---|---|
| Pull request | 1,000 cases per opcode against the pinned commit | Fails the check |
Push to main |
Every case against the pinned commit | Fails the check |
| Weekly | Every case against whatever upstream holds now | Opens a pull request if it passes, an issue naming the opcodes if it does not |
A pinned oracle keeps a build reproducible and stops an upstream edit from turning this repository red with no commit of its own to explain it. It is also how a repository stops noticing that the thing judging it has moved. .github/workflows/suite-watch.yml closes that gap without ever moving the pin on its own.
The model is chosen at construction, the same way it is across the sibling repositories.
from spc700 import Cpu, SparseMemory, describe
describe("s-smp").name
# 'spc700'
cpu = Cpu(SparseMemory(), model="spc700")| Model | Address bits | Notes |
|---|---|---|
spc700 |
16 | Sony SPC700, the core inside the S-SMP. Aliases: spc, ssmp, smp, sonyspc700 |
Note
Unlike the 65xx parts, the SPC700 has essentially one form. Sony built it into the S-SMP and never sold it separately, so there is no family of pin variants or licensee revisions to model. The catalogue exists anyway, because a hardware difference discovered later should mean adding an entry rather than restructuring the package.
spc700/
__init__.py the package, and the model chosen at construction
core.py the interpreter
opcodes.py one table driving both execution and disassembly
memory.py memory that holds what it held
models.py what each part is
version.py rewritten by the release job and by nothing else
conformance/
fetch.py partial, sparse, pinned checkout of the suites
singlestep.py runs the suite and reports what disagreed
suites.json which suite, which commit
Each module has its tests beside it as <module>.test.py, so a module and the cases that pin its behaviour are read together.
for f in spc700/*.test.py conformance/*.test.py; do python3 "$f"; done| Suite | File | Covers |
|---|---|---|
| Core | spc700/core.test.py |
Every opcode, addressing, arithmetic, both decimal adjusts, the division, bit instructions, branches, stack, reset |
| Opcode table | spc700/opcodes.test.py |
Decoding, bit and call index extraction, disassembly |
| Memory | spc700/memory.test.py |
Scrambled fills, sparse derivation, address wrapping, seeding |
| Models | spc700/models.test.py |
The catalogue, alias matching, construction |
| Conformance harness | conformance/singlestep.test.py |
State construction, comparison, reporting, the command line |
| Suite fetch | conformance/fetch.test.py |
Checkout shape, timeouts, failure reporting, against a real git repository |
Nothing is stubbed. The fetch tests run git against a repository built in a temporary directory, because a stand-in for git would only prove the stand-in works.
Coverage is enforced at 100% of statements and branches by pyproject.toml, so a new branch without a test fails the build rather than quietly lowering the number.
| Command | Description |
|---|---|
ruff format . |
Format |
ruff check . |
Lint |
python3 -m coverage run -a <file> |
Run one test file under coverage |
python3 -m coverage report |
Coverage, which fails below 100% |
python3 conformance/fetch.py <dir> |
Fetch the pinned suite |
python3 conformance/singlestep.py <dir> [limit] [filter] |
Run the suite |
| Convention | Source |
|---|---|
| Commit format | Conventional Commits |
| Releases | semantic-release, driven by .releaserc.json |
| Lint and format | Ruff, configured in pyproject.toml |
| Test layout | <module>.test.py beside the module it covers |
This project follows Semantic Versioning, and every release is tagged from main by semantic-release. See releases.
Important
While the version is below 1.0.0, the public interface may change on a minor release. Pin an exact version if that matters to you.
Does this emulate the audio DSP as well?
No. This is the SPC700 processor core: instruction execution and its memory interface. The S-DSP that turns the driver's register writes into sound is separate hardware with a separate job, and mixing the two into one package would make neither testable on its own.
Why scramble memory instead of zeroing it?
Because audio RAM is not zeroed at power on. Code that reads a byte it never wrote is reading whatever the hardware settled on, and that read is a bug. Zero-filled memory makes it invisible: the value is stable, plausible, and usually harmless, so the test passes and the console does not. Pass fill=0 when you genuinely want zeroes, and the decision is then recorded in the code.
Is it cycle accurate?
No, and it does not claim to be. It is instruction accurate, verified per instruction against a suite that specifies the full before and after state. The suite also publishes a cycle-by-cycle bus trace per case; that trace is not currently checked, and any future claim of cycle accuracy would be measured against it rather than asserted.
Why is there only one model when the sibling repository has several?
Because the hardware only has one. The 65xx family was sold to many customers in many packages, so its differences are real and worth modelling. The SPC700 shipped inside one chip in one console.