200 lines
6.4 KiB
Python
200 lines
6.4 KiB
Python
"""ROM-compile service — turns a chip-program source file into raw ROM bytes.
|
|
|
|
Backs the `POST /api/compile-rom` endpoint that the frontend's "Compile" button
|
|
uses when the active file is a chip-program file (.s / .asm / .hex / .bin).
|
|
The output is base64-encoded bytes the frontend can stash in the chip's
|
|
`romBytes` property; the chip then reads them at chip_setup via
|
|
`vx_rom_size` / `vx_rom_read`.
|
|
|
|
Supported targets and formats:
|
|
|
|
target=8080 format=asm → in-tree two-pass Intel 8080 assembler (asm8080.py)
|
|
target=* format=hex → Intel HEX parser
|
|
target=* format=bin → raw byte passthrough (already-compiled ROM)
|
|
|
|
Future: z80/8086/4004 assemblers and SDCC for C sources.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import logging
|
|
from importlib import import_module
|
|
from typing import Literal
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Lazy-load the asm8080 module from this services dir so the import stays
|
|
# explicit (no implicit sys.path manipulation).
|
|
_ASM_MODULE = None
|
|
_ASM_Z80_MODULE = None
|
|
|
|
|
|
def _asm8080():
|
|
global _ASM_MODULE
|
|
if _ASM_MODULE is None:
|
|
_ASM_MODULE = import_module("app.services.asm8080")
|
|
return _ASM_MODULE
|
|
|
|
|
|
def _asmz80():
|
|
global _ASM_Z80_MODULE
|
|
if _ASM_Z80_MODULE is None:
|
|
_ASM_Z80_MODULE = import_module("app.services.asmz80")
|
|
return _ASM_Z80_MODULE
|
|
|
|
|
|
Target = Literal["8080", "z80", "8086", "4004"]
|
|
Format = Literal["asm", "hex", "bin"]
|
|
|
|
|
|
def parse_intel_hex(text: str) -> bytes:
|
|
"""Parse Intel HEX records into a flat byte buffer. Unknown record types
|
|
are skipped; data records (type 0x00) are placed at their declared address.
|
|
"""
|
|
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: # EOF record
|
|
break
|
|
if rtype != 0x00: # ignore extended-segment, start-address, etc.
|
|
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)
|
|
|
|
|
|
def assemble_8080(source: str) -> bytes:
|
|
"""Two-pass Intel 8080 assembler. Returns raw ROM bytes."""
|
|
return _asm8080().assemble(source)
|
|
|
|
|
|
def assemble_z80(source: str) -> bytes:
|
|
"""Two-pass Zilog Z80 assembler. Returns raw ROM bytes."""
|
|
return _asmz80().assemble(source)
|
|
|
|
|
|
async def compile_rom(source: str, target: Target, fmt: Format) -> dict:
|
|
"""Compile a chip-program source to ROM bytes.
|
|
|
|
Returns a dict shaped like:
|
|
{ success, rom_base64, byte_size, stderr, error }
|
|
"""
|
|
fmt_l = fmt.lower()
|
|
tgt_l = target.lower()
|
|
|
|
if fmt_l == "bin":
|
|
# Source may arrive as a hex string (frontend pre-encodes binary)
|
|
# or as raw text. Try hex-encoded first.
|
|
clean = "".join(source.split())
|
|
try:
|
|
data = bytes.fromhex(clean)
|
|
except ValueError:
|
|
data = source.encode("latin1")
|
|
return {
|
|
"success": True,
|
|
"rom_base64": base64.b64encode(data).decode("ascii"),
|
|
"byte_size": len(data),
|
|
"stderr": "",
|
|
"error": None,
|
|
}
|
|
|
|
if fmt_l == "hex":
|
|
try:
|
|
data = parse_intel_hex(source)
|
|
except Exception as e: # noqa: BLE001
|
|
return {
|
|
"success": False,
|
|
"rom_base64": None,
|
|
"byte_size": 0,
|
|
"stderr": "",
|
|
"error": f"Intel HEX parse failed: {e}",
|
|
}
|
|
return {
|
|
"success": True,
|
|
"rom_base64": base64.b64encode(data).decode("ascii"),
|
|
"byte_size": len(data),
|
|
"stderr": "",
|
|
"error": None,
|
|
}
|
|
|
|
if fmt_l == "asm":
|
|
if tgt_l == "8080":
|
|
try:
|
|
data = assemble_8080(source)
|
|
except Exception as e: # noqa: BLE001
|
|
return {
|
|
"success": False, "rom_base64": None, "byte_size": 0,
|
|
"stderr": "", "error": f"asm8080: {e}",
|
|
}
|
|
elif tgt_l == "z80":
|
|
try:
|
|
data = assemble_z80(source)
|
|
except Exception as e: # noqa: BLE001
|
|
return {
|
|
"success": False, "rom_base64": None, "byte_size": 0,
|
|
"stderr": "", "error": f"asm-z80: {e}",
|
|
}
|
|
else:
|
|
return {
|
|
"success": False, "rom_base64": None, "byte_size": 0,
|
|
"stderr": "",
|
|
"error": (
|
|
f"No assembler for target {target!r} yet. Supported: "
|
|
"8080, z80. Try uploading a .hex or .bin compiled with "
|
|
"your own toolchain."
|
|
),
|
|
}
|
|
return {
|
|
"success": True,
|
|
"rom_base64": base64.b64encode(data).decode("ascii"),
|
|
"byte_size": len(data),
|
|
"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 / c.",
|
|
}
|