diff --git a/Dockerfile.standalone b/Dockerfile.standalone index cecd998d..0812e81a 100644 --- a/Dockerfile.standalone +++ b/Dockerfile.standalone @@ -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 diff --git a/backend/app/api/routes/compile_rom.py b/backend/app/api/routes/compile_rom.py index 31d26a76..bd43a7c5 100644 --- a/backend/app/api/routes/compile_rom.py +++ b/backend/app/api/routes/compile_rom.py @@ -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)) diff --git a/backend/app/services/c_compile.py b/backend/app/services/c_compile.py new file mode 100644 index 00000000..6bee4d27 --- /dev/null +++ b/backend/app/services/c_compile.py @@ -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, + } diff --git a/backend/app/services/rom_compile.py b/backend/app/services/rom_compile.py index ad4a6228..3ffe398e 100644 --- a/backend/app/services/rom_compile.py +++ b/backend/app/services/rom_compile.py @@ -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.", } diff --git a/frontend/src/components/editor/EditorToolbar.tsx b/frontend/src/components/editor/EditorToolbar.tsx index 3e2d2433..5ae83ef5 100644 --- a/frontend/src/components/editor/EditorToolbar.tsx +++ b/frontend/src/components/editor/EditorToolbar.tsx @@ -161,22 +161,36 @@ 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(); - // Empty programFile → also accept (single-chip canvases). - return prog === '' || prog === activeFile.name; - }); + 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(); + return prog === '' || prog === activeFile.name; + }); if (chips.length === 0) { addLog({ timestamp: new Date(), diff --git a/frontend/src/data/examples-retro-intel.ts b/frontend/src/data/examples-retro-intel.ts index 15967855..e779b875 100644 --- a/frontend/src/data/examples-retro-intel.ts +++ b/frontend/src/data/examples-retro-intel.ts @@ -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', + }, + ], + }, ]; diff --git a/frontend/src/services/romCompileService.ts b/frontend/src/services/romCompileService.ts index 0192a3a8..d17d9088 100644 --- a/frontend/src/services/romCompileService.ts +++ b/frontend/src/services/romCompileService.ts @@ -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'; }