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:
parent
96ef12b585
commit
0e2f0790db
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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.",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue