A complete CHIP-8 emulator implemented as a pure ONNX computation graph.
No custom operators, no execution-provider extensions, no Python in the
hot loop — the entire CPU lives inside the model. Standard
ONNX Runtime 1.26 CPU EP runs it unmodified.
This is not a machine-learning model. There are no weights, no training,
no inference in the statistical sense. It is a CPU expressed as a
computation graph, because it turns out ONNX has all the primitives a
CPU needs: bitwise ops, indexed memory access, conditional dispatch, and
a Loop operator that's Turing-complete with the rest of the op set.
Snake title screen rendered by the model
The image above is the output of Run() on chip8_snake_demo.onnx
— a uint8[90, 32, 64] tensor returned in one call, with no inputs.
Models
File
Inputs
Outputs
Notes
chip8_cpu.onnx
RAM + register state + key state + trip count
Updated RAM + register state
Load any CHIP-8 ROM into RAM, call once per game tick
chip8_snake_demo.onnx
(none — fully baked)
uint8[90, 32, 64] frame stack
Single Run() returns a 90-frame movie of the Snake title screen
The two models share the same inner CPU. The demo wraps that CPU in an
outer Loop whose body executes 30 instructions per frame and whose
scan output is the framebuffer — that's how ONNX naturally accumulates
"one frame per outer iteration" into a single tensor.
How it works
State
CHIP-8 has 4 KB of RAM, sixteen 8-bit registers, a 12-bit program counter,
a 12-bit index register, a tiny stack, two 8-bit timers, and a 64×32
monochrome display. All of it lives in three tensors that flow through the
Loop as carried dependencies:
The Loop body — one CHIP-8 instruction per iteration
Each iteration of the inner Loop fetches, decodes, and executes one
CHIP-8 instruction.
The dispatch is branchless: every opcode subgraph runs every iteration,
and a chain of Where ops at the end picks the one whose pattern matches.
This trades wasted work for a flat, regular graph that's much easier to
read than a 35-deep nested If ladder — and it doesn't actually cost more
in practice, because the per-node overhead of ONNX Runtime's Loop is the
dominant cost anyway.
The outer structure — wrapping the CPU into a movie
Scan outputs — values emitted per iteration and concatenated along
a new leading axis (here: the framebuffer).
The movie model exploits scan outputs: one outer iteration = one frame
emitted = one row of the final frames tensor. There is no Python loop
anywhere in this pipeline; the entire 90-frame animation is produced
inside a single sess.run() call.
What's in the model file
A Loop operator wrapping a single GraphProto body. The body has
~600 nodes — mostly Gather, ScatterND, BitShift, BitwiseAnd,
Equal, and Where. No node is a custom op. The whole chip8_cpu.onnx
file is ~40 KB.
Model file structure graph
Usage
Run the bundled demo
python
1import onnxruntime as ort
2import numpy as np
3from PIL import Image
45sess = ort.InferenceSession("chip8_snake_demo.onnx",6 providers=["CPUExecutionProvider"])7frames,= sess.run(None,{})# no inputs!89print(frames.shape, frames.dtype)10# (90, 32, 64) uint81112# Save the final frame13final =(frames[-1]>0).astype(np.uint8)*25514Image.fromarray(final, mode="L").resize((512,256)).save("snake_frame.png")
That's the entire usage. No tokenizer, no preprocessing, no postprocessing
— Run() returns pixels.
Load any CHIP-8 ROM into the generic CPU
python
1import onnxruntime as ort
2import numpy as np
34sess = ort.InferenceSession("chip8_cpu.onnx",5 providers=["CPUExecutionProvider"])67# Initial state8definitial_ram(rom:bytes)-> np.ndarray:9 FONT =bytes.fromhex("F0909090F02060202070F010F080F0F010F010F0"10"9090F01010F080F010F0F080F090F0F010204040"11"F090F090F0F090F010F0F090F09090E090E090E0"12"F0808080F0E0909090E0F080F080F0F080F08080")13 ram = np.zeros(4096, dtype=np.uint8)14 ram[0x50:0x50+80]= np.frombuffer(FONT, dtype=np.uint8)15 ram[0x200:0x200+len(rom)]= np.frombuffer(rom, dtype=np.uint8)16return ram
1718regs = np.zeros(40, dtype=np.int32)19regs[17]=0x200# PC20regs[21]=0xAB# RNG seed21ram = initial_ram(open("snake.ch8","rb").read())22display = np.zeros(2048, dtype=np.uint8)23keys = np.zeros(16, dtype=np.uint8)2425# Run 30 CHIP-8 instructions per tick26for tick inrange(60):27 regs, ram, display = sess.run(None,{28"regs_in": regs,29"ram_in": ram,30"display_in": display,31"keys": keys,32"trip_count": np.array(30, dtype=np.int64),33})3435# `display` is now a uint8[2048] framebuffer — reshape to (32, 64) to view.
A bundled ROM (snake.ch8, public domain) is included so you can try this
straight away.
Why this exists
It's a question about what ONNX is. The ONNX operator set, once it grew
Loop, If, the Bitwise* family (opset 18) and ScatterND with
reduction modes, became Turing-complete in any reasonable sense of the
phrase. This model demonstrates the consequence: ONNX Runtime, designed
for evaluating neural networks, can also evaluate arbitrary
computations — including a working game console — without modification.
Concretely the project exists to:
Probe how far the standard ONNX op set actually goes as a general
computation target.
Demonstrate that Loop + Scan output give you a clean way to express
"run a program for N steps, return one tensor per step" in a single
Run() call.
Provide a tiny, complete, self-contained reference for anyone who wants
to do non-ML things with ONNX.
If you want to play CHIP-8 games, there are a hundred better emulators.
If you want to see what happens when you treat ONNX as a programming
language, you're in the right place.
Performance
Measured on a Windows ARM64 laptop with ONNX Runtime 1.26 CPU EP, opset 21:
This is plenty fast for CHIP-8 — most CHIP-8 games target 500–1000 Hz CPU
and the model handily exceeds that. ONNX-as-a-CPU is not, however, going
to be competitive with anything that wants to run a real-time emulator
properly; per-node overhead in Loop bodies dominates everything.
What's inside the box
.
├── chip8_cpu.onnx # Generic CHIP-8 CPU (40 KB)
├── chip8_snake_demo.onnx # Self-contained Snake-title movie (48 KB)
├── snake.ch8 # Public-domain Snake ROM (1.4 KB)
├── example_output.gif # What you get when you Run() the demo
└── README.md # This file