feat(chips): C-to-Z80 compile via SDCC + LED chaser example

Adds a third format to /api/compile-rom: `c` (C source compiled by SDCC
to Z80 bytes). Same chip-program flow as 8080/Z80 asm — write C in a
project file, click Compile, click Run.

Backend:
- backend/app/services/c_compile.py — async SDCC wrapper. Locates the
  sdcc binary on PATH (or via SDCC env var, or common Windows install
  paths) and shells out with target=mz80 + --code-loc 0x100 --data-loc
  0x8000. Parses the resulting Intel HEX into raw ROM bytes. Pure 8080
  is rejected with a clear error (SDCC has no 8080 backend; Z80 ROMs
  also run on the i8080-cpu chip if you avoid Z80-only ops).
- rom_compile.py: compile_rom is now async; the new c branch delegates
  to c_compile. compile_rom_endpoint awaits it.

Frontend:
- romCompileService: RomFormat gains 'c'; formatForFile maps .c/.cpp to
  'c'. isChipProgramFile intentionally still excludes .c — disambiguation
  happens at the EditorToolbar level.
- EditorToolbar: the chip-program path also fires when a custom-chip
  has programFile === activeFile.name (regardless of extension). That
  lets .c files route to /api/compile-rom (SDCC) when bound to a CPU
  chip, while .c files NOT bound to any chip continue to route to
  arduino-cli as before.

Docker:
- Dockerfile.standalone adds `sdcc` to the apt-get install list, so the
  prod image ships with SDCC out of the box.

Example:
- /examples/z80-led-chaser-c — z80-cpu chip + chaser.c (a Larson
  scanner written in C with __at() MMIO definitions). Compiles cleanly
  with SDCC's --code-loc 0x100 default crt0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
David Montero Crespo 2026-05-19 00:31:10 -03:00
parent 96ef12b585
commit 0e2f0790db
7 changed files with 382 additions and 18 deletions

View File

@ -169,6 +169,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
ccache \
qemu-system-arm \
qemu-utils \
sdcc \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* \
&& pip install --no-cache-dir packaging

View File

@ -49,7 +49,7 @@ async def compile_rom_endpoint(request: RomCompileRequest):
if not request.source.strip() and request.format != "bin":
raise HTTPException(status_code=422, detail="`source` cannot be empty.")
try:
result = compile_rom(request.source, request.target, request.format) # type: ignore[arg-type]
result = await compile_rom(request.source, request.target, request.format) # type: ignore[arg-type]
except Exception as e: # noqa: BLE001
logger.exception("ROM compile failed")
raise HTTPException(status_code=500, detail=str(e))

View File

@ -0,0 +1,187 @@
"""C-to-retro-CPU compile service via SDCC.
Backs the `format=c` branch of `POST /api/compile-rom`. Compiles a C source
file with SDCC targeting the chip's emulated ISA (Z80 or 8080), then
extracts the program bytes from the resulting Intel HEX so the chip can
load them via vx_rom_read.
SDCC install:
Linux/macOS (Docker prod image):
apt-get install -y sdcc
Windows dev:
Download from https://sdcc.sourceforge.net/snap.php and run the
installer; or `winget install --id=SDCC.sdcc`. Add the install
`bin/` directory to PATH.
The service auto-discovers `sdcc` on PATH; if not present, the endpoint
returns a clear "SDCC not installed" message rather than an opaque crash.
Memory layout the chip expects (matches i8080-cpu / z80-cpu):
ROM at 0x0000..0x7FFF (--code-loc 0)
RAM at 0x8000..0xBFFF (--data-loc 0x8000)
Stack grows down from 0xBFFF
SDCC's `--code-loc 0` puts the entry stub at 0; if the user writes a
`void main(void)` it gets wrapped in the standard crt0 + jumped to.
"""
from __future__ import annotations
import asyncio
import os
import shutil
import subprocess
import tempfile
from pathlib import Path
from typing import Literal
def _find_sdcc() -> str | None:
"""Locate the sdcc binary. Honours SDCC env var first, then PATH."""
env = os.environ.get("SDCC")
if env and Path(env).is_file():
return env
for name in ("sdcc", "sdcc.exe"):
path = shutil.which(name)
if path:
return path
# Common Windows install spots not always added to PATH.
candidates = [
Path("C:/Program Files/SDCC/bin/sdcc.exe"),
Path("C:/Program Files (x86)/SDCC/bin/sdcc.exe"),
Path("C:/sdcc/bin/sdcc.exe"),
]
for c in candidates:
if c.is_file():
return str(c)
return None
CTarget = Literal["z80", "8080"]
def parse_intel_hex(text: str) -> bytes:
"""Parse Intel HEX records into a flat byte buffer.
Mirrors rom_compile.parse_intel_hex but lives here so c_compile is
self-contained.
"""
out = bytearray()
for raw in text.splitlines():
line = raw.strip()
if not line.startswith(":"):
continue
try:
length = int(line[1:3], 16)
addr = int(line[3:7], 16)
rtype = int(line[7:9], 16)
except ValueError:
continue
if rtype == 0x01:
break
if rtype != 0x00:
continue
data_hex = line[9 : 9 + length * 2]
try:
data = bytes.fromhex(data_hex)
except ValueError:
continue
end = addr + len(data)
if end > len(out):
out.extend(b"\x00" * (end - len(out)))
out[addr:end] = data
return bytes(out)
async def compile_c(source: str, target: CTarget) -> dict:
"""Compile C source to ROM bytes using SDCC.
Returns:
{ success, rom_base64 | None, byte_size, stderr, error }
Caller is expected to base64 the returned bytes; this fn returns
raw bytes via the dict's 'rom_bytes' key (rom_compile.py wraps).
"""
sdcc = _find_sdcc()
if not sdcc:
return {
"success": False,
"rom_bytes": b"",
"stderr": "",
"error": (
"SDCC not installed. Install with `apt-get install sdcc` on "
"Linux/Docker, or `winget install SDCC.sdcc` on Windows. "
"Then set the SDCC env var or add sdcc to PATH."
),
}
tgt = target.lower()
if tgt == "z80":
flag = "-mz80"
elif tgt == "8080":
# SDCC's 8080 target name is `mgbz80` (Game Boy variant) or
# `mz80` — pure 8080 lacks a dedicated SDCC backend; closest is
# mz80 with the user avoiding Z80-only ops. Report a friendly
# error since pure 8080 C isn't widely useful today.
return {
"success": False,
"rom_bytes": b"",
"stderr": "",
"error": (
"Pure Intel 8080 has no SDCC backend. Use target=z80 "
"(Z80 is binary-compatible with 8080 — your code runs on "
"the i8080-cpu chip too if you avoid Z80-only instructions)."
),
}
else:
return {
"success": False,
"rom_bytes": b"",
"stderr": "",
"error": f"SDCC target {target!r} is not supported.",
}
with tempfile.TemporaryDirectory() as tmp_dir:
tmp = Path(tmp_dir)
c_path = tmp / "program.c"
c_path.write_text(source, encoding="utf-8")
# Let SDCC link its default crt0 at 0x0000 — it places a small init
# stub there which sets SP and jumps to _main(). User code lives
# right after the stub (typically <0x80 bytes in).
cmd = [
sdcc, flag,
"--code-loc", "0x0100",
"--data-loc", "0x8000",
"-o", str(tmp / "program.ihx"),
str(c_path),
]
def _run() -> subprocess.CompletedProcess:
return subprocess.run(cmd, capture_output=True, text=True,
cwd=str(tmp), timeout=60)
try:
result = await asyncio.to_thread(_run)
except subprocess.TimeoutExpired:
return {
"success": False, "rom_bytes": b"",
"stderr": "", "error": "SDCC timed out after 60s.",
}
ihx_path = tmp / "program.ihx"
if result.returncode != 0 or not ihx_path.is_file():
return {
"success": False,
"rom_bytes": b"",
"stderr": (result.stdout or "") + (result.stderr or ""),
"error": "sdcc exited with a non-zero status",
}
rom = parse_intel_hex(ihx_path.read_text(encoding="utf-8"))
return {
"success": True,
"rom_bytes": rom,
"stderr": result.stderr,
"error": None,
}

View File

@ -88,7 +88,7 @@ def assemble_z80(source: str) -> bytes:
return _asmz80().assemble(source)
def compile_rom(source: str, target: Target, fmt: Format) -> dict:
async def compile_rom(source: str, target: Target, fmt: Format) -> dict:
"""Compile a chip-program source to ROM bytes.
Returns a dict shaped like:
@ -166,10 +166,34 @@ def compile_rom(source: str, target: Target, fmt: Format) -> dict:
"stderr": "", "error": None,
}
if fmt_l == "c":
# C source via SDCC (Z80 only — pure 8080 has no SDCC backend; Z80
# is binary-compat with 8080 so the same .c can target both chips).
from app.services.c_compile import compile_c # lazy — keeps the
# import out of the
# asm/hex/bin path.
result = await compile_c(source, tgt_l if tgt_l in ("z80", "8080") else "z80")
rom = result.get("rom_bytes", b"")
if not result.get("success"):
return {
"success": False,
"rom_base64": None,
"byte_size": 0,
"stderr": result.get("stderr", ""),
"error": result.get("error", "C compile failed"),
}
return {
"success": True,
"rom_base64": base64.b64encode(rom).decode("ascii"),
"byte_size": len(rom),
"stderr": result.get("stderr", ""),
"error": None,
}
return {
"success": False,
"rom_base64": None,
"byte_size": 0,
"stderr": "",
"error": f"Unknown format {fmt!r} — expected asm / hex / bin.",
"error": f"Unknown format {fmt!r} — expected asm / hex / bin / c.",
}

View File

@ -161,20 +161,34 @@ export const EditorToolbar = ({
trackCompileCode();
// ── Chip-program path ───────────────────────────────────────────────
// If the editor's active file is a chip-program file (.s/.asm/.hex/.bin)
// we don't compile Arduino code at all — we assemble or parse it into
// ROM bytes via /api/compile-rom and stash the result on every
// custom-chip component that points at this filename through its
// `programFile` property. The chip's emulator then reads the bytes on
// chip_setup via vx_rom_size / vx_rom_read.
// If the editor's active file is a chip-program file we don't compile
// Arduino code — we assemble/compile it into ROM bytes via
// /api/compile-rom and stash the result on every custom-chip component
// that points at this filename through its `programFile` property. The
// chip's emulator then reads the bytes on chip_setup via vx_rom_size /
// vx_rom_read.
//
// A file is "chip program" when EITHER its extension is unambiguous
// (.s/.asm/.hex/.bin) OR some custom-chip on the canvas has
// programFile === activeFile.name. The latter lets .c files route to
// SDCC instead of arduino-cli when wired to a CPU chip.
const activeFile = files.find((f) => f.id === useEditorStore.getState().activeFileId);
if (activeFile && isChipProgramFile(activeFile.name)) {
try {
const components = useSimulatorStore.getState().components;
const chips = components.filter((c) => {
const componentsForCompile = useSimulatorStore.getState().components;
const chipsBoundToFile = activeFile
? componentsForCompile.filter((c) => {
if (c.metadataId !== 'custom-chip') return false;
const prog = String((c.properties as any)?.programFile ?? '').trim();
return prog === activeFile.name;
})
: [];
if (activeFile && (isChipProgramFile(activeFile.name) || chipsBoundToFile.length > 0)) {
try {
const chips = chipsBoundToFile.length > 0
? chipsBoundToFile
: componentsForCompile.filter((c) => {
if (c.metadataId !== 'custom-chip') return false;
const prog = String((c.properties as any)?.programFile ?? '').trim();
// Empty programFile → also accept (single-chip canvases).
return prog === '' || prog === activeFile.name;
});
if (chips.length === 0) {

View File

@ -29,6 +29,69 @@ import i8080CpuJ from '../components/customChips/examples/intel/i8080-cpu.
import z80CpuC from '../components/customChips/examples/intel/z80-cpu.c?raw';
import z80CpuJ from '../components/customChips/examples/intel/z80-cpu.chip.json?raw';
const chaserZ80C = `/* LED chaser written in C, compiled to Z80 by SDCC.
*
* Demonstrates that you can program the Z80 chip in C (not just asm).
* The backend runs:
* sdcc -mz80 --code-loc 0x100 --data-loc 0x8000 program.c
* and feeds the resulting Intel HEX into the chip via vx_rom_read.
*
* The MMIO addresses (0xC000 LED, 0xC003 BTN, 0xC001 UART_DATA,
* 0xC002 UART_STAT) match the z80-cpu chip's memory map.
*
* Behaviour: walks a single LED back and forth across the 8 outputs
* (true Larson scanner, with direction reversal).
*/
#define LED_OUT (*(volatile unsigned char __at(0xC000)))
#define BTN_IN (*(volatile unsigned char __at(0xC003)))
static void delay(unsigned int loops) {
while (loops--) {
__asm
nop
nop
nop
nop
__endasm;
}
}
void main(void) {
unsigned char bit = 0x01;
char dir = 1; /* +1 = walking left, -1 = walking right */
while (1) {
LED_OUT = bit;
delay(5000);
if (dir > 0) {
bit <<= 1;
if (bit == 0x80) dir = -1;
} else {
bit >>= 1;
if (bit == 0x01) dir = 1;
}
}
}
`;
const chaserZ80CSketch = `// Z80 LED chaser — C source compiled by SDCC.
//
// The companion file is chaser.c. With sdcc installed on the backend,
// clicking Compile shells out to:
// sdcc -mz80 --code-loc 0x100 --data-loc 0x8000 chaser.c
// and the resulting Intel HEX is loaded into the z80-cpu chip's ROM.
//
// Steps:
// 1. Click chaser.c in the file explorer.
// 2. Click Compile. If SDCC isn't installed yet the toolbar will
// say so — install with \`apt-get install sdcc\` (Linux) or
// \`winget install SDCC.sdcc\` (Windows), then restart the backend.
// 3. Click Run. A single LED bounces back and forth across the 8 outputs.
void setup() {}
void loop() {}
`;
const larsonZ80Asm = `; Larson Scanner / Knight Rider in Z80 assembly.
;
; A single LED walks left across 8 LEDs forever. Uses JR/DJNZ/RLCA --
@ -490,4 +553,72 @@ export const retroIntelExamples: ExampleProject[] = [
},
],
},
// ── Z80 LED chaser in C (SDCC) ─────────────────────────────────────
{
id: 'z80-led-chaser-c',
title: 'Z80 LED Chaser (C via SDCC)',
description:
'Same z80-cpu chip, but the program is written in C and compiled by SDCC at compile time. ' +
'A single LED walks back and forth Larson-style. Requires sdcc installed on the backend.',
category: 'circuits',
difficulty: 'advanced',
boardType: 'arduino-uno',
tags: ['retro', 'z80', 'zilog', 'cpu', 'leds', 'larson', 'c', 'sdcc', 'wasm', 'custom-chip', 'programmable'],
code: chaserZ80CSketch,
files: [
{ name: 'sketch.ino', content: chaserZ80CSketch },
{ name: 'chaser.c', content: chaserZ80C },
],
components: [
{
type: 'custom-chip',
id: 'z80cpu',
x: 380,
y: 120,
properties: {
chipName: 'Z80 CPU (programmable)',
sourceC: z80CpuC,
chipJson: z80CpuJ,
wasmBase64: '',
romBytes: '',
programFile: 'chaser.c',
programTarget: 'z80',
},
},
...[0, 1, 2, 3, 4, 5, 6, 7].map((i) => ({
type: 'wokwi-led',
id: `led-${i}`,
x: 700 + i * 50,
y: 120,
properties: { color: 'red' },
})),
],
wires: [
...[0, 1, 2, 3, 4, 5, 6, 7].map((i) => ({
id: `wire-led-${i}`,
start: { componentId: 'z80cpu', pinName: `LED${i}` },
end: { componentId: `led-${i}`, pinName: 'A' },
color: '#facc15',
})),
...[0, 1, 2, 3, 4, 5, 6, 7].map((i) => ({
id: `wire-led-${i}-gnd`,
start: { componentId: `led-${i}`, pinName: 'C' },
end: { componentId: 'arduino-uno', pinName: 'GND' },
color: '#000000',
})),
{
id: 'wire-z80c-vcc',
start: { componentId: 'z80cpu', pinName: 'VCC' },
end: { componentId: 'arduino-uno', pinName: '5V' },
color: '#e74c3c',
},
{
id: 'wire-z80c-gnd',
start: { componentId: 'z80cpu', pinName: 'GND' },
end: { componentId: 'arduino-uno', pinName: 'GND' },
color: '#000000',
},
],
},
];

View File

@ -6,7 +6,7 @@
*/
export type RomTarget = '8080' | 'z80' | '8086' | '4004';
export type RomFormat = 'asm' | 'hex' | 'bin';
export type RomFormat = 'asm' | 'hex' | 'bin' | 'c';
export interface RomCompileResult {
success: boolean;
@ -42,7 +42,13 @@ export async function compileRom(
return (await res.json()) as RomCompileResult;
}
/** Classify a filename as a chip-program file (vs an Arduino sketch). */
/** Classify a filename as a chip-program file (vs an Arduino sketch).
*
* `.c` is intentionally NOT in the always-list Arduino sketches use .c
* too. The toolbar disambiguates by checking whether a custom-chip on
* the canvas has `programFile === activeFile.name`. If yes, .c is a chip
* program (SDCC route); if no, it's an Arduino sketch (arduino-cli route).
*/
export function isChipProgramFile(name: string): boolean {
const lower = name.toLowerCase();
return (
@ -58,6 +64,7 @@ export function formatForFile(name: string): RomFormat {
const lower = name.toLowerCase();
if (lower.endsWith('.hex')) return 'hex';
if (lower.endsWith('.bin')) return 'bin';
if (lower.endsWith('.c') || lower.endsWith('.cpp')) return 'c';
return 'asm';
}