proto-gen is a declarative Python framework for describing computer systems and generating emulator cores in C.
The core idea is:
- Model a machine in Python with clocks, chips, buses, memory maps, I/O registers, signals, DMA, and CPUs.
- Describe CPU instructions and device behavior as small Python functions.
- Walk those Python functions as ASTs and transpile them into C.
- Emit a single C program that can be compiled as a fast AST-walking interpreter and, optionally, wrapped in an SDL3 host.
This repository already includes examples ranging from a tiny educational CPU to a Game Boy-scale system definition.
This framework sits between handwritten emulators and hardware DSLs:
- Higher level than writing a full emulator directly in C.
- More concrete and software-oriented than HDL.
- Focused on emulator generation, not hardware synthesis.
It is a good fit when you want to:
- prototype a new emulator architecture quickly,
- keep system structure declarative and inspectable,
- define opcodes in Python instead of manually duplicating C switch cases,
- generate portable C that can be compiled with
gccor similar toolchains, - experiment with single-CPU, multi-CPU, banked-memory, or cycle-aware systems.
Based on the code and tests in this repository, the framework currently supports:
- declarative system modeling with
Board,Chip,Clock,MemoryBus,MemoryRegion,MemoryBank,MemoryController,RegisterBlock,SignalLine,Port, andDMAChannel, - CPU definitions with registers, register pairs, flags, opcode tables, opcode families, prefix tables, and interrupt vectors,
- Python-to-C transpilation for a focused subset of Python expressions and statements,
- raw C escape hatches anywhere the Python subset is too restrictive,
- generated C interpreters with per-opcode dispatch,
- single-CPU and multi-CPU boards,
- memory banking, guarded access, write intercepts, and overlays,
- cycle-counted and cycle-accurate generation modes,
- DMA hooks and interrupt dispatch generation,
- an SDL3 host generator for windowing, rendering, audio, input, menus, and config handling.
src/proto- framework runtime and code generatorsexamples/fibonacci.py- smallest end-to-end exampleexamples/tinyboy.py- single CPU, banked ROM, timer, signalsexamples/tinysuper.py- dual CPU, separate buses, portsexamples/cycle_accurate.py- access-timed generationexamples/game_boy/game_boy.py- large real-world system definitionexamples/game_boy/game_boy_host.py- SDL3 host generationtests- feature coverage and behavior reference
The package metadata names the project proto-gen, while the import package is proto.
python -m venv .venv
. .venv/Scripts/activate
pip install -e .[dev]To run tests:
pytestSome integration tests require gcc in PATH.
Think in layers:
CPUDefinitiondescribes the ISA and instruction bodies.Chippackages a CPU core or peripheral state and behavior.MemoryBusand related memory classes describe the address space.Boardassembles chips, buses, clocks, signals, and ports into a whole machine.BoardCodeGeneratoremits a complete C implementation.SDLHostandHostCodeGeneratoroptionally wrap the generated board with an SDL frontend.
The smallest useful flow is:
from proto import (
Clock, Chip, Board, CPUDefinition,
MemoryRegion, MemoryBus, MemoryAccessLevel,
BoardCodeGenerator,
)
master = Clock("master", 1_000_000)
ram = MemoryRegion("ram", 256, MemoryAccessLevel.ReadWrite)
rom = MemoryRegion("rom", 32768, MemoryAccessLevel.ReadOnly)
cpu = CPUDefinition("tiny8", data_width=8, address_width=16)
cpu.add_register("A", 8)
@cpu.opcode(0x00, "NOP", cycles=1)
def nop(cpu):
pass
@cpu.opcode(0x01, "LDA #imm8", cycles=2)
def lda_imm8(cpu):
cpu.A = read_imm8()
@cpu.opcode(0x0F, "HALT", cycles=1)
def halt(cpu):
cpu.halted = 1
cpu_chip = Chip("cpu", clock=master)
cpu_chip.set_cpu_core(cpu)
cpu_chip.add_internal_memory(ram)
cpu_chip.add_internal_memory(rom)
bus = MemoryBus("main", address_bits=16)
bus.map(0x0000, 0x00FF, region=ram)
bus.map(0x8000, 0xFFFF, region=rom)
bus.set_fallback(read=0xFF)
cpu_chip.set_bus(bus)
board = Board("TinyDemo", comment="Minimal generated emulator")
board.set_master_clock(master)
board.add_chip(cpu_chip)
board.add_bus(bus)
c_code = BoardCodeGenerator(board).generate()
with open("tinydemo.c", "w", encoding="utf-8") as f:
f.write(c_code)Then compile the generated C:
gcc -O2 -o tinydemo tinydemo.cFor a full runnable example with a generated main(), start with examples/fibonacci.py.
Instruction and device handlers are not executed by the emulator at runtime. Instead, the framework:
- reads the Python function source with
inspect, - parses it into an AST,
- rewrites known constructs into C expressions/statements,
- injects the resulting code into generated helper functions, opcode cases, register handlers, tick handlers, and so on.
That means your Python handler bodies act like a small DSL.
Examples of supported conveniences:
cpu.A = read_imm8()cpu.F.Z = cpu.A == 0cpu.HL = read_imm16()mem_write(cpu.HL, cpu.A)signal_assert("timer_irq")- typed locals such as
x: uint8 = 0 - array declarations such as
buf: array[uint8, 160] = None - opcode families with variant substitution such as
cpu.reg = cpu.reg | 1
More detail is in docs/transpiler-subset.md.
Read the examples in this order:
examples/fibonacci.pyexamples/tinyboy.pyexamples/tinysuper.pyexamples/cycle_accurate.pyexamples/game_boy/game_boy.pyexamples/game_boy/game_boy_host.py
That progression mirrors the framework itself:
- tiny single-core board,
- memory controllers and signals,
- multi-CPU scheduling and ports,
- timed bus accesses and synchronization,
- full-system modeling,
- generated desktop host.
Typical project flow:
- Define clocks and memory regions.
- Define one or more CPU cores with
CPUDefinition. - Attach CPU cores and peripherals to
Chipobjects. - Describe the bus topology and register maps.
- Build a
Board. - Generate C with
BoardCodeGenerator. - Optionally wrap it with
SDLHostandHostCodeGenerator. - Compile the emitted C with your platform toolchain.
See docs/workflow-and-examples.md for a more detailed walk-through.
You do not need to force everything through the transpiler. The framework also supports raw C injection for:
- opcodes via
add_opcode_raw, - register block read/write handlers,
- memory controller write handlers and bank resolvers,
- chip helpers,
- DMA transfers,
- tick handlers,
- step preambles,
- SDL host post-init and ROM loading code.
This is useful when:
- a construct is not supported by the AST transpiler,
- you want exact control over emitted C,
- you are incrementally porting an existing emulator into the declarative model.
There are two timing styles:
- Normal mode: opcode entries contribute
cyclesdirectly tocycle_count, and board step logic catches peripherals up afterward. - Cycle-accurate mode: bus accesses and
internal_op()calls account for timing, and synchronization happens during execution rather than after the opcode finishes.
If you care about memory wait states, peripheral synchronization on every access, or DMA arbitration, read examples/cycle_accurate.py alongside docs/model-and-architecture.md.
The host layer lets you keep emulator logic in the generated board while declaring desktop concerns separately:
- window size and title,
- palette conversion,
- audio stream setup,
- input mapping,
- ROM loading,
- menu bar and config,
- render/audio/input hook binding.
See docs/host-layer.md.
This project is already useful, but it is still early-stage and opinionated.
- The transpiler intentionally supports only a subset of Python.
- Handler functions should stay simple and side-effect oriented.
- When you need something outside the subset, use raw C hooks.
- Generated output is monolithic C rather than a multi-file project structure.
- The best source of truth for supported patterns today is the combination of
tests/test_transpiler.pyand the example systems. - The data model exposes
Chip.set_init(), but board initialization is currently driven by field defaults plus generatedmain()or host setup code; chip init handlers are not presently wired into emitted board init code.
docs/model-and-architecture.mddocs/transpiler-subset.mddocs/workflow-and-examples.mddocs/host-layer.md
The package metadata marks the project as alpha, which matches the repository well: the foundation is already broad, the examples are ambitious, and the tests cover a lot of behavior, but the API and supported subset are still evolving.