Skip to content

Repository files navigation

SPC700

An interpreter for the Sony SPC700, the processor inside the SNES audio unit.



CI Conformance Coverage Python License

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
# 0x42

The problem

The 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.

The solution

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.

Every opcode, no gaps

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 awkward ones, verbatim

The division past its useful range, both decimal adjusts, and the inverted half carry are written the way the silicon does them.

Undefined state stays undefined

SparseMemory derives an unwritten byte from its address, so such a read is arbitrary, reproducible, and not zero, at no allocation cost.

A disassembler in the same table

One table drives both execution and listing, so a new opcode cannot be added to one and forgotten in the other.

The oracle is pinned, and watched

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.

No dependencies

Pure Python, standard library only. The release tooling is the sole node_modules, and it never ships.

Quick start

Prerequisites

Tool Version Install
Python >= 3.12 python.org

Setup

git clone https://github.com/gufranco/sony-spc700-python.git
cd sony-spc700-python

Verify

python3 spc700/core.test.py
# Ran 105 tests in 0.03s
# OK

The instructions worth knowing about

These are the four places an implementation written from a summary of the instruction set will disagree with a console.

The division keeps going past the answer

cpu.y, cpu.a, cpu.x = 0x00, 0x0A, 0x03
cpu.step()
# a = 3, y = 1, an ordinary quotient and remainder

Once 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.

The decimal adjust reads what it just wrote

cpu.a, cpu.c = 0x9A, False
cpu.step()
# a = 0x00, c = True

DAA 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.

The half carry inverts after a subtraction

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 direct page moves

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.

What "nothing starts clean" means

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 zero

Audio 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.

Conformance

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 not

The 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.

How the pin is kept honest

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.

Models

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.

Project structure

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.

Tests

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.

Development

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

Project conventions

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

Versioning

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.

FAQ

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.

License

MIT

About

Sony SPC700 interpreter in Python for the SNES audio processor. All 256 opcodes, validated against all 256,000 SingleStepTests cases with zero failures, 100 percent statement and branch coverage, and no assumption that memory or registers start clean

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages