velxio/backend/app/services/asmz80.py

583 lines
22 KiB
Python

"""Two-pass Zilog Z80 assembler.
Covers the practical subset most LED / UART demos need:
* Loads: LD r,n / LD r,r' / LD rp,nn / LD (nn),A / LD A,(nn)
LD (nn),HL / LD HL,(nn) / LD A,(BC|DE) / LD (BC|DE),A
LD SP,HL
* 8-bit ALU: ADD/ADC/SUB/SBC/AND/XOR/OR/CP A,r and A,n
* 8-bit unary: INC r / DEC r
* 16-bit: INC rp / DEC rp / ADD HL,rp
* Control: JP nn / JP cc,nn / JR n / JR cc,n / DJNZ n
CALL nn / CALL cc,nn / RET / RET cc / RST n / NOP / HALT
* Stack: PUSH rp / POP rp
* I/O: IN A,(n) / OUT (n),A
* Exchanges: EX DE,HL / EX (SP),HL / EX AF,AF' / EXX
* Interrupts: DI / EI / IM 0|1|2 / RETI / RETN
* Rotates: RLCA / RRCA / RLA / RRA
* Block: LDI / LDIR / LDD / LDDR
* Misc: CPL / SCF / CCF / DAA / NEG
Not covered today: CB-prefix bit ops (BIT/SET/RES/RL/RR/SLA/SRA/SRL),
DD/FD-prefix IX/IY indexed addressing, ED-prefix variants beyond what is
listed. The chip emulator implements them; the assembler can be extended
when a demo needs them.
Output is a raw byte stream; the user adds `ORG`/labels/`DB`/`DW` in the
usual way. Used by `POST /api/compile-rom` with `target=z80`.
"""
from __future__ import annotations
import re
# ── Register tables (Z80 encoding) ────────────────────────────────────────
R8 = {'B': 0, 'C': 1, 'D': 2, 'E': 3, 'H': 4, 'L': 5, '(HL)': 6, 'A': 7}
RP = {'BC': 0, 'DE': 1, 'HL': 2, 'SP': 3}
RP2 = {'BC': 0, 'DE': 1, 'HL': 2, 'AF': 3} # used by PUSH/POP
CC = {'NZ': 0, 'Z': 1, 'NC': 2, 'C': 3, 'PO': 4, 'PE': 5, 'P': 6, 'M': 7}
CC_REL = {'NZ': 0, 'Z': 1, 'NC': 2, 'C': 3} # 8-bit-displacement relative branches
def _atom(tok: str, labels: dict[str, int]) -> int:
tok = tok.strip()
if tok.startswith("'") and tok.endswith("'") and len(tok) == 3:
return ord(tok[1])
if tok in labels:
return labels[tok]
if tok.startswith('0x') or tok.startswith('0X'):
return int(tok, 16)
if tok.endswith('h') or tok.endswith('H'):
return int(tok[:-1], 16)
if tok.endswith('b') or tok.endswith('B'):
return int(tok[:-1], 2)
if tok.lstrip('-').isdigit():
return int(tok)
raise ValueError(f"asm-z80: can't parse {tok!r}")
def parse_imm(tok: str, labels: dict[str, int]) -> int:
tok = tok.strip().rstrip(',').strip()
# Tiny expression parser — supports a single + or - between two atoms,
# ignoring operators that sit inside char literals.
for op_char, sign in [('+', 1), ('-', -1)]:
in_q = False
for i, ch in enumerate(tok):
if ch == "'":
in_q = not in_q
elif ch == op_char and not in_q and i > 0:
return _atom(tok[:i], labels) + sign * _atom(tok[i + 1:], labels)
return _atom(tok, labels)
def imm16(val: int) -> tuple[int, int]:
val &= 0xFFFF
return (val & 0xFF, (val >> 8) & 0xFF)
# ── Parsing helpers ───────────────────────────────────────────────────────
def _split_args(arg_str: str) -> list[str]:
"""Split on commas that aren't inside parens or char literals."""
out: list[str] = []
buf = ''
depth = 0
in_q = False
for ch in arg_str:
if ch == "'":
in_q = not in_q
buf += ch
elif ch == '(' and not in_q:
depth += 1; buf += ch
elif ch == ')' and not in_q:
depth -= 1; buf += ch
elif ch == ',' and depth == 0 and not in_q:
out.append(buf.strip())
buf = ''
else:
buf += ch
if buf.strip():
out.append(buf.strip())
return out
def _is_indirect(tok: str) -> bool:
tok = tok.strip()
return tok.startswith('(') and tok.endswith(')')
def _strip_paren(tok: str) -> str:
return tok.strip()[1:-1].strip()
# ── Encoders ──────────────────────────────────────────────────────────────
def _enc_ld(args: list[str], labels: dict[str, int]) -> bytes:
if len(args) != 2:
raise ValueError(f"LD needs two operands, got {args}")
dst, src = args[0].upper(), args[1].upper()
# LD rp,nn (BC/DE/HL/SP <- immediate)
if dst in RP and not _is_indirect(src):
opc = 0x01 | (RP[dst] << 4)
lo, hi = imm16(parse_imm(src, labels))
return bytes([opc, lo, hi])
# LD (nn),A / LD (nn),HL
if _is_indirect(dst):
inner = _strip_paren(dst)
if inner == 'BC' and src == 'A': return bytes([0x02])
if inner == 'DE' and src == 'A': return bytes([0x12])
if inner == 'HL':
if src in R8 and src != '(HL)':
return bytes([0x70 | R8[src]])
# LD (HL),n
lo, hi = imm16(parse_imm(src, labels))
return bytes([0x36, lo])
# LD (nn),A or (nn),HL
lo, hi = imm16(parse_imm(inner, labels))
if src == 'A': return bytes([0x32, lo, hi])
if src == 'HL': return bytes([0x22, lo, hi])
if src == 'BC': return bytes([0xED, 0x43, lo, hi])
if src == 'DE': return bytes([0xED, 0x53, lo, hi])
if src == 'SP': return bytes([0xED, 0x73, lo, hi])
raise ValueError(f"LD (nn),{src} not supported")
# LD A,(BC) / LD A,(DE) / LD HL,(nn) / LD A,(nn)
if _is_indirect(src):
inner = _strip_paren(src)
if dst == 'A' and inner == 'BC': return bytes([0x0A])
if dst == 'A' and inner == 'DE': return bytes([0x1A])
if dst in R8 and dst != '(HL)' and inner == 'HL':
return bytes([0x46 | (R8[dst] << 3)])
lo, hi = imm16(parse_imm(inner, labels))
if dst == 'A': return bytes([0x3A, lo, hi])
if dst == 'HL': return bytes([0x2A, lo, hi])
if dst == 'BC': return bytes([0xED, 0x4B, lo, hi])
if dst == 'DE': return bytes([0xED, 0x5B, lo, hi])
if dst == 'SP': return bytes([0xED, 0x7B, lo, hi])
raise ValueError(f"LD {dst},({inner}) not supported")
# LD SP,HL
if dst == 'SP' and src == 'HL':
return bytes([0xF9])
# LD r,r'
if dst in R8 and src in R8:
if dst == '(HL)' and src == '(HL)':
raise ValueError("LD (HL),(HL) is invalid (would be HALT)")
return bytes([0x40 | (R8[dst] << 3) | R8[src]])
# LD r,n
if dst in R8:
return bytes([0x06 | (R8[dst] << 3), parse_imm(src, labels) & 0xFF])
# LD I,A / LD A,I / LD R,A / LD A,R
if dst == 'I' and src == 'A': return bytes([0xED, 0x47])
if dst == 'R' and src == 'A': return bytes([0xED, 0x4F])
if dst == 'A' and src == 'I': return bytes([0xED, 0x57])
if dst == 'A' and src == 'R': return bytes([0xED, 0x5F])
raise ValueError(f"LD {dst},{src} not supported")
def _enc_alu(base8: int, immcode: int):
"""Build an encoder for an A,r / A,n ALU op.
base8 is the 8-bit register-form base (e.g. 0x80 for ADD A,r).
immcode is the 2-byte immediate-form opcode (e.g. 0xC6 for ADD A,n).
"""
def enc(args: list[str], labels: dict[str, int]) -> bytes:
# Z80 syntax is `ADD A,r` but plain `ADD r` is also accepted.
if len(args) == 2 and args[0].upper() == 'A':
args = [args[1]]
if len(args) != 1:
raise ValueError(f"ALU needs one operand (besides A), got {args}")
tok = args[0].upper()
if tok in R8:
return bytes([base8 | R8[tok]])
# Immediate
return bytes([immcode, parse_imm(args[0], labels) & 0xFF])
return enc
def _enc_inc_dec(is_inc: bool):
def enc(args: list[str], labels: dict[str, int]) -> bytes:
if len(args) != 1:
raise ValueError("INC/DEC takes one operand")
tok = args[0].upper()
if tok in RP:
base = 0x03 if is_inc else 0x0B
return bytes([base | (RP[tok] << 4)])
if tok in R8:
base = 0x04 if is_inc else 0x05
return bytes([base | (R8[tok] << 3)])
raise ValueError(f"INC/DEC {tok!r} not supported")
return enc
def _enc_push_pop(is_push: bool):
def enc(args: list[str], labels: dict[str, int]) -> bytes:
if len(args) != 1 or args[0].upper() not in RP2:
raise ValueError(f"PUSH/POP needs BC/DE/HL/AF, got {args}")
base = 0xC5 if is_push else 0xC1
return bytes([base | (RP2[args[0].upper()] << 4)])
return enc
def _enc_addhl(args: list[str], labels: dict[str, int]) -> bytes:
# Accept ADD HL,rp.
if len(args) == 2 and args[0].upper() == 'HL':
rp = args[1].upper()
if rp in RP:
return bytes([0x09 | (RP[rp] << 4)])
raise ValueError(f"ADD HL,rp expected, got {args}")
def _enc_jp(args: list[str], labels: dict[str, int]) -> bytes:
if len(args) == 1:
if args[0].upper() == '(HL)':
return bytes([0xE9])
lo, hi = imm16(parse_imm(args[0], labels))
return bytes([0xC3, lo, hi])
if len(args) == 2 and args[0].upper() in CC:
lo, hi = imm16(parse_imm(args[1], labels))
return bytes([0xC2 | (CC[args[0].upper()] << 3), lo, hi])
raise ValueError(f"JP {args} not supported")
def _rel(addr_from_after_instr: int, target: int) -> int:
delta = target - addr_from_after_instr
if delta < -128 or delta > 127:
raise ValueError(f"JR/DJNZ out of range: delta={delta}")
return delta & 0xFF
def _enc_jr(pc_after, args: list[str], labels: dict[str, int]) -> bytes:
if len(args) == 1:
tgt = parse_imm(args[0], labels)
return bytes([0x18, _rel(pc_after, tgt)])
if len(args) == 2 and args[0].upper() in CC_REL:
tgt = parse_imm(args[1], labels)
return bytes([0x20 | (CC_REL[args[0].upper()] << 3), _rel(pc_after, tgt)])
raise ValueError(f"JR {args} not supported")
def _enc_djnz(pc_after, args: list[str], labels: dict[str, int]) -> bytes:
if len(args) != 1:
raise ValueError(f"DJNZ takes one operand")
tgt = parse_imm(args[0], labels)
return bytes([0x10, _rel(pc_after, tgt)])
def _enc_call(args: list[str], labels: dict[str, int]) -> bytes:
if len(args) == 1:
lo, hi = imm16(parse_imm(args[0], labels))
return bytes([0xCD, lo, hi])
if len(args) == 2 and args[0].upper() in CC:
lo, hi = imm16(parse_imm(args[1], labels))
return bytes([0xC4 | (CC[args[0].upper()] << 3), lo, hi])
raise ValueError(f"CALL {args} not supported")
def _enc_ret(args: list[str], labels: dict[str, int]) -> bytes:
if len(args) == 0:
return bytes([0xC9])
if len(args) == 1 and args[0].upper() in CC:
return bytes([0xC0 | (CC[args[0].upper()] << 3)])
raise ValueError(f"RET {args} not supported")
def _enc_rst(args: list[str], labels: dict[str, int]) -> bytes:
if len(args) != 1:
raise ValueError("RST takes one operand")
n = parse_imm(args[0], labels)
if n not in (0x00, 0x08, 0x10, 0x18, 0x20, 0x28, 0x30, 0x38):
raise ValueError(f"RST {n:02x}h is not a legal vector")
return bytes([0xC7 | n])
def _enc_in(args: list[str], labels: dict[str, int]) -> bytes:
if len(args) == 2 and args[0].upper() == 'A' and _is_indirect(args[1]):
return bytes([0xDB, parse_imm(_strip_paren(args[1]), labels) & 0xFF])
raise ValueError(f"IN {args} not supported")
def _enc_out(args: list[str], labels: dict[str, int]) -> bytes:
if len(args) == 2 and _is_indirect(args[0]) and args[1].upper() == 'A':
return bytes([0xD3, parse_imm(_strip_paren(args[0]), labels) & 0xFF])
raise ValueError(f"OUT {args} not supported")
def _enc_ex(args: list[str], labels: dict[str, int]) -> bytes:
if len(args) != 2:
raise ValueError("EX needs two operands")
a, b = args[0].upper(), args[1].upper()
if a == 'DE' and b == 'HL': return bytes([0xEB])
if a == 'AF' and b == "AF'": return bytes([0x08])
if a == "AF" and b == "AF'": return bytes([0x08])
if a == '(SP)' and b == 'HL': return bytes([0xE3])
raise ValueError(f"EX {args} not supported")
def _enc_im(args: list[str], labels: dict[str, int]) -> bytes:
if len(args) != 1:
raise ValueError("IM takes one operand")
n = parse_imm(args[0], labels)
return {0: bytes([0xED, 0x46]), 1: bytes([0xED, 0x56]), 2: bytes([0xED, 0x5E])}.get(n) or _bad(f"IM {n}")
def _bad(msg: str):
raise ValueError(msg)
def _simple(opc: int):
if isinstance(opc, int):
return lambda a, l: bytes([opc])
return lambda a, l: bytes(opc)
def _two(b0: int, b1: int):
return lambda a, l: bytes([b0, b1])
# Build dispatch tables.
INSTR_SIZE: dict[str, int] = {}
INSTR_ENCODE: dict[str, callable] = {} # type: ignore[type-arg]
def _reg(name: str, size: int, fn) -> None:
INSTR_SIZE[name] = size
INSTR_ENCODE[name] = fn
# ── Static (no operands) ─────────────────────────────────────────────────
_reg('NOP', 1, _simple(0x00))
_reg('HALT', 1, _simple(0x76))
_reg('CPL', 1, _simple(0x2F))
_reg('SCF', 1, _simple(0x37))
_reg('CCF', 1, _simple(0x3F))
_reg('DAA', 1, _simple(0x27))
_reg('RLCA', 1, _simple(0x07))
_reg('RRCA', 1, _simple(0x0F))
_reg('RLA', 1, _simple(0x17))
_reg('RRA', 1, _simple(0x1F))
_reg('DI', 1, _simple(0xF3))
_reg('EI', 1, _simple(0xFB))
_reg('EXX', 1, _simple(0xD9))
_reg('NEG', 2, _two(0xED, 0x44))
_reg('RETI', 2, _two(0xED, 0x4D))
_reg('RETN', 2, _two(0xED, 0x45))
_reg('LDI', 2, _two(0xED, 0xA0))
_reg('LDIR', 2, _two(0xED, 0xB0))
_reg('LDD', 2, _two(0xED, 0xA8))
_reg('LDDR', 2, _two(0xED, 0xB8))
_reg('CPI', 2, _two(0xED, 0xA1))
_reg('CPIR', 2, _two(0xED, 0xB1))
# ── ALU 8-bit ─────────────────────────────────────────────────────────────
def _reg_alu(name: str, base8: int, immcode: int) -> None:
INSTR_ENCODE[name] = _enc_alu(base8, immcode)
# Size depends on operand — set in pass 1 dynamically (see assemble()).
for n, base, imm in [('ADD', 0x80, 0xC6), ('ADC', 0x88, 0xCE),
('SUB', 0x90, 0xD6), ('SBC', 0x98, 0xDE),
('AND', 0xA0, 0xE6), ('XOR', 0xA8, 0xEE),
('OR', 0xB0, 0xF6), ('CP', 0xB8, 0xFE)]:
_reg_alu(n, base, imm)
INSTR_ENCODE['INC'] = _enc_inc_dec(True)
INSTR_ENCODE['DEC'] = _enc_inc_dec(False)
INSTR_ENCODE['LD'] = _enc_ld
INSTR_ENCODE['PUSH'] = _enc_push_pop(True)
INSTR_ENCODE['POP'] = _enc_push_pop(False)
INSTR_ENCODE['JP'] = _enc_jp
INSTR_ENCODE['CALL'] = _enc_call
INSTR_ENCODE['RET'] = _enc_ret
INSTR_ENCODE['RST'] = _enc_rst
INSTR_ENCODE['IN'] = _enc_in
INSTR_ENCODE['OUT'] = _enc_out
INSTR_ENCODE['EX'] = _enc_ex
INSTR_ENCODE['IM'] = _enc_im
# JR and DJNZ are PC-relative — they need pc_after to encode.
# Handled specially in the assemble() pass below.
INSTR_ENCODE['JR'] = '__JR__' # sentinel
INSTR_ENCODE['DJNZ'] = '__DJNZ__'
INSTR_ENCODE['ADD'] = _enc_alu(0x80, 0xC6)
# ── Static-size table (used only when encoder doesn't depend on operand) ─
STATIC_SIZE = {
'NOP': 1, 'HALT': 1, 'CPL': 1, 'SCF': 1, 'CCF': 1, 'DAA': 1,
'RLCA': 1, 'RRCA': 1, 'RLA': 1, 'RRA': 1, 'DI': 1, 'EI': 1, 'EXX': 1,
'NEG': 2, 'RETI': 2, 'RETN': 2, 'LDI': 2, 'LDIR': 2, 'LDD': 2,
'LDDR': 2, 'CPI': 2, 'CPIR': 2,
'RST': 1, 'JR': 2, 'DJNZ': 2, 'IM': 2,
'IN': 2, 'OUT': 2,
'PUSH': 1, 'POP': 1, 'INC': 1, 'DEC': 1, 'EX': 1,
}
def _size_for(mnem: str, args: list[str]) -> int:
"""Estimate the size of an instruction (pass 1) without resolving labels.
Conservative — returns the LARGER of the possible encodings when in doubt
so labels resolve to stable addresses.
"""
if mnem in STATIC_SIZE:
return STATIC_SIZE[mnem]
if mnem == 'LD':
# Distinguish LD r,r' (1) vs LD r,n (2) vs LD rp,nn (3) vs LD (nn),A (3 or 4)
if len(args) != 2: return 1
a, b = args[0].upper(), args[1].upper()
if _is_indirect(a):
inner = _strip_paren(a)
if inner in ('BC', 'DE'): return 1
if inner == 'HL':
if b in R8 and b != '(HL)': return 1
return 2 # LD (HL),n
# LD (nn),X
if b in ('A',): return 3
if b == 'HL': return 3
if b in ('BC', 'DE', 'SP'): return 4
return 3
if _is_indirect(b):
inner = _strip_paren(b)
if inner in ('BC', 'DE'): return 1
if inner == 'HL': return 1
if a == 'A': return 3
if a == 'HL': return 3
if a in ('BC', 'DE', 'SP'): return 4
return 3
if a in RP: return 3
if a == 'SP' and b == 'HL': return 1
if a in R8 and b in R8: return 1
return 2 # LD r,n
if mnem in ('ADD', 'ADC', 'SUB', 'SBC', 'AND', 'XOR', 'OR', 'CP'):
# `ADD HL,rp` is 1 byte, ALU A,r is 1, ALU A,n is 2.
if mnem == 'ADD' and len(args) == 2 and args[0].upper() == 'HL':
return 1
# Strip leading "A," if present.
if len(args) == 2 and args[0].upper() == 'A':
args = [args[1]]
if len(args) == 1 and args[0].upper() in R8: return 1
return 2
if mnem == 'JP':
if len(args) == 1 and args[0].upper() == '(HL)': return 1
return 3
if mnem == 'CALL':
return 3
if mnem == 'RET':
return 1
raise ValueError(f"asm-z80 pass1: unknown mnemonic {mnem!r}")
def assemble(src: str) -> bytes:
"""Two-pass assembler: pass 1 sizes + labels, pass 2 emits."""
# ── Tokenize ─────────────────────────────────────────────────────────
lines: list[tuple[str | None, str | None, list[str]]] = []
for raw in src.splitlines():
text = raw.split(';', 1)[0].rstrip()
if not text.strip():
lines.append((None, None, [])); continue
label = None
# Find first ':' outside parens / quotes — same scheme as asm8080.
in_q = False; depth = 0; colon = -1
for i, ch in enumerate(text):
if ch == "'": in_q = not in_q
elif ch == '(': depth += 1
elif ch == ')': depth = max(0, depth - 1)
elif ch == ':' and not in_q and depth == 0:
colon = i; break
if colon >= 0:
label = text[:colon].strip()
text = text[colon + 1:]
text = text.strip()
if not text:
lines.append((label, None, [])); continue
m = re.match(r'\s*(\S+)\s*(.*)$', text)
if not m:
lines.append((label, None, [])); continue
mnem = m.group(1).upper()
args = _split_args(m.group(2).strip())
lines.append((label, mnem, args))
# ── Pass 1: estimate sizes + assign labels ───────────────────────────
labels: dict[str, int] = {}
sizes: list[int] = []
pc = 0
for (lbl, mnem, args) in lines:
if lbl:
labels[lbl] = pc
if mnem is None:
sizes.append(0); continue
if mnem == 'ORG':
new_pc = parse_imm(args[0], labels)
if new_pc < pc:
raise ValueError(f"ORG cannot move backwards (at {pc} -> {new_pc})")
sizes.append(new_pc - pc); pc = new_pc; continue
if mnem == 'DB':
n = 0
for a in args:
if a.startswith('"') and a.endswith('"'):
n += len(bytes(a[1:-1], 'utf-8').decode('unicode_escape'))
else:
n += 1
sizes.append(n); pc += n; continue
if mnem == 'DW':
sizes.append(2 * len(args)); pc += 2 * len(args); continue
sz = _size_for(mnem, args)
sizes.append(sz); pc += sz
# ── Pass 2: emit ──────────────────────────────────────────────────────
out = bytearray()
pc = 0
for (i, (lbl, mnem, args)) in enumerate(lines):
if mnem is None:
continue
if mnem == 'ORG':
tgt = parse_imm(args[0], labels)
while pc < tgt:
out.append(0x00); pc += 1
continue
if mnem == 'DB':
for a in args:
if a.startswith('"') and a.endswith('"'):
blob = bytes(a[1:-1], 'utf-8').decode('unicode_escape').encode('latin1')
out.extend(blob); pc += len(blob)
else:
v = parse_imm(a, labels) & 0xFF
out.append(v); pc += 1
continue
if mnem == 'DW':
for a in args:
lo, hi = imm16(parse_imm(a, labels))
out.append(lo); out.append(hi); pc += 2
continue
enc = INSTR_ENCODE.get(mnem)
if enc is None:
raise ValueError(f"asm-z80: unknown mnemonic {mnem!r}")
# JR / DJNZ need pc_after (pc + size) for relative encoding.
if enc == '__JR__':
data = _enc_jr(pc + 2, args, labels)
elif enc == '__DJNZ__':
data = _enc_djnz(pc + 2, args, labels)
elif mnem == 'ADD' and len(args) == 2 and args[0].upper() == 'HL':
data = _enc_addhl(args, labels)
else:
data = enc(args, labels)
out.extend(data); pc += len(data)
return bytes(out)
if __name__ == '__main__':
import sys
if len(sys.argv) < 2:
print('usage: asmz80.py <input.s>'); sys.exit(2)
rom = assemble(open(sys.argv[1], encoding='utf-8').read())
print(f"// {len(rom)} bytes")
print('static const uint8_t ROM[] = {')
for i in range(0, len(rom), 12):
chunk = ', '.join(f'0x{b:02x}' for b in rom[i:i+12])
print(f" {chunk},")
print('};')