188 lines
5.9 KiB
Python
188 lines
5.9 KiB
Python
"""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,
|
|
}
|