Source code for romulan.build_rom

"""Build a 32 KB ROM image for the Pico-as-ROM 65C02 system.

The 32 KB image maps to CPU addresses $8000-$FFFF.
File offset $0000 = CPU address $8000
File offset $7FFC = CPU address $FFFC (reset vector low byte)
File offset $7FFF = CPU address $FFFF
"""

import os
import re
import sys
from pathlib import Path
from typing import Dict, List

ROM_SIZE = 0x8000  # 32 KB
ROM_BASE_ADDR = 0x8000  # ROM starts at CPU address $8000
ERROR_COUNTER = 0  # Global counter for errors encountered during ROM build


[docs] class InvalidInstructionError(Exception): """Raised when a byte sequence contains an undefined 65C02 opcode.""" def __init__(self, message): """Initialize the error with a human-readable message. Args: message: Description of the invalid instruction or operand. """ super().__init__(message) self.message = message
[docs] def __str__(self): """Return the error message. Returns: The message passed to the constructor. """ return self.message
[docs] class SkippedInstructionError(Exception): """Raised when a required instruction address is missing from the ROM dump.""" def __init__(self, message): """Initialize the error with a human-readable message. Args: message: Description of the skipped instruction address. """ super().__init__(message) self.message = message
[docs] def __str__(self): """Return the error message. Returns: The message passed to the constructor. """ return self.message
[docs] def cpu_to_offset(cpu_addr: int) -> int: """Convert a CPU address ($8000-$FFFF) to a file offset (0-$7FFF). Args: cpu_addr: A 65C02 address in the ROM region. Returns: The corresponding byte offset in a 32 KB ROM file. Raises: ValueError: If ``cpu_addr`` is outside ``$8000``–``$FFFF``. """ if not (ROM_BASE_ADDR <= cpu_addr <= 0xFFFF): raise ValueError( f"CPU address ${cpu_addr:04X} is outside the ROM region " f"(${ROM_BASE_ADDR:04X}-$FFFF)" ) return cpu_addr - ROM_BASE_ADDR
[docs] def parse_hex_file(path: Path) -> dict[int, int]: """Parse an annotated hex dump file into a dict of CPU address -> byte. Expected line format:: 0x0000 0x18 @ CLC 0x0001 0xA9 @ LDA 0x5 File addresses are in the range ``0x0000``–``0x7FFF`` and are mapped to CPU addresses by adding :data:`ROM_BASE_ADDR` (``0x8000``). Everything after ``@`` on a line is treated as a comment and ignored. Args: path: Path to the annotated hex dump file. Returns: A mapping from CPU address to byte value. Raises: ValueError: If a line cannot be parsed or an address is out of range. """ data: dict[int, int] = {} line_pattern = re.compile(r"^\s*0x([0-9A-Fa-f]+)\s+0x([0-9A-Fa-f]{2})") with open(path, "r", encoding="utf-8") as fh: for line_num, line in enumerate(fh, start=1): line = line.split("@")[0].strip() # strip comments if not line: continue match = line_pattern.match(line) if not match: raise ValueError(f"Cannot parse line {line_num}: {line.strip()!r}") file_addr = int(match.group(1), 16) byte_val = int(match.group(2), 16) if not (0 <= file_addr < ROM_SIZE): raise ValueError( f"Line {line_num}: file address 0x{file_addr:04X} is outside " f"the valid range 0x0000-0x{ROM_SIZE - 1:04X}" ) cpu_addr = file_addr + ROM_BASE_ADDR data[cpu_addr] = byte_val return data
[docs] def verify_instructions(data: List[int], error_list: List) -> None: """Validate opcode bytes and append errors instead of raising immediately. Distinguishes undefined opcodes from immediate or address operands that follow a valid instruction byte. Errors are appended to ``error_list`` and :data:`ERROR_COUNTER` is incremented. Args: data: Byte values parsed from the ROM image. error_list: List that receives human-readable error messages. """ invalid_instructions = [0x02, 0x03, 0x0B, 0x13, 0x1B, 0x22, 0x23, 0x2B, 0x33, 0x3B, 0x42, 0x43, 0x44, 0x4B, 0x53, 0x54, 0x5B,0x5C, 0x62, 0x63, 0x6B, 0x73, 0x7B, 0x82, 0x83, 0x8B, 0x93, 0x9B, 0xA3, 0xAB, 0xB3, 0xBB, 0xC2, 0xC3, 0xD3, 0xD4, 0xDC, 0xE2, 0xE3, 0xEB, 0xF3, 0xF4, 0xFB, 0xFC] instructions_with_immediate = [0x09, 0x29, 0x49, 0x69, 0x89, 0xA0, 0xA2, 0xA9, 0xC0, 0xC9, 0xE0, 0xE9] instructions_with_address = [0x00, 0x0C, 0x0D, 0x0E, 0x19, 0x1C, 0x1D, 0x1E, 0x20, 0x2A, 0x2C, 0x2D, 0x2E, 0x39, 0x3C, 0x3D, 0x3E, 0x4C, 0x4D, 0x4E, 0x59, 0x5D, 0x5E, 0x6C, 0x6D, 0x6E, 0x79, 0x7C, 0x7D, 0x7E, 0x8C, 0x8D, 0x8E, 0x99, 0x9C, 0x9D, 0x9E, 0xAC, 0xAD, 0xAE, 0xBC, 0xBD, 0xBE, 0xCC, 0xCD, 0xCE, 0xD9, 0xDD, 0xDE, 0xEC, 0xED, 0xEE, 0xF9, 0xFD, 0xFE] global ERROR_COUNTER for d in data: if 0x00 <= d <= 0xFF: if d in invalid_instructions: position = data.index(d) if ( data[position - 1] in instructions_with_immediate or data[position - 1] in instructions_with_address ): continue elif ( position - 2 >= 0 and data[position - 2] in instructions_with_address ): continue else: error_list.append(f"Invalid instruction: ${d} is undefined") ERROR_COUNTER += 1 else: error_list.append( f"Invalid instruction: ${d} is out of range (0x00 - 0xFF)" ) ERROR_COUNTER += 1
[docs] def verify_instruction_order(data: List[int], error_list: List) -> None: """Check that file addresses are contiguous (no gaps before the vectors). Args: data: File-offset addresses from the parsed ROM dump. error_list: List that receives human-readable error messages. """ sorted_data = sorted(data) global ERROR_COUNTER for i in range(len(sorted_data) - 1): current = sorted_data[i] next_addr = sorted_data[i + 1] if current in (0x7FFC, 0x7FFD, 0x7FFE, 0x7FFF) or next_addr in (0x7FFC, 0x7FFD, 0x7FFE, 0x7FFF): break if next_addr != current + 1: error_list.append(f"Skipped instruction: ${current + 1:04X} is missing") ERROR_COUNTER += 1
[docs] def error_processing(data_dict: Dict[int, int]) -> List: """Run opcode and address-order validation on a parsed ROM dump. Args: data_dict: Mapping of CPU address to byte value from :func:`parse_hex_file`. Returns: A list of human-readable error messages (empty when validation passes). """ byte_list = list(data_dict.values()) instruction_list = [] for instruction in data_dict.keys(): new_instruction = instruction - ROM_BASE_ADDR instruction_list.append(new_instruction) error_list_final = [] verify_instructions(data=byte_list, error_list=error_list_final) verify_instruction_order(data=instruction_list, error_list=error_list_final) return error_list_final
[docs] def build_rom(input_path: Path, output_path: Path) -> None: """Parse a hex dump file and write a 32 KB ROM binary. Runs opcode and address-order validation before writing. Unused bytes are filled with ``$EA`` (NOP). The reset and IRQ/BRK vectors at ``$FFFC``–``$FFFF`` must be present in the input or the build fails. Side effects: Resets :data:`ERROR_COUNTER`, may print errors and call ``sys.exit(1)``, creates parent directories for ``output_path``, writes the binary file, and prints a summary to stdout. Args: input_path: Path to the annotated hex dump file. output_path: Destination path for the 32 KB ``.bin`` file. Raises: ValueError: If parsing fails or required vectors are missing. """ global ERROR_COUNTER ERROR_COUNTER = 0 parsed = parse_hex_file(input_path) master_error_list = error_processing(parsed) if ERROR_COUNTER > 0: print(f"Encountered {ERROR_COUNTER} errors while building ROM:") for error in master_error_list: print(f" {error}") print("ROM build failed due to errors.") sys.exit(1) rom = bytearray([0xEA] * ROM_SIZE) for cpu_addr, byte_val in parsed.items(): offset = cpu_to_offset(cpu_addr) rom[offset] = byte_val required_vectors = { 0xFFFC: "reset vector (low)", 0xFFFD: "reset vector (high)", 0xFFFE: "IRQ/BRK vector (low)", 0xFFFF: "IRQ/BRK vector (high)", } missing = [] for addr, desc in required_vectors.items(): offset = cpu_to_offset(addr) if rom[offset] == 0xEA: missing.append(f" ${addr:04X} ({desc})") if missing: raise ValueError("ROM is missing required vectors:\n" + "\n".join(missing)) os.makedirs(output_path.parent, exist_ok=True) with open(output_path, "wb") as fh: fh.write(rom) print(f"Wrote {len(rom)} bytes to {output_path}") print(f" Reset vector → ${rom[0x7FFD]:02X}{rom[0x7FFC]:02X}") print(f" IRQ vector → ${rom[0x7FFF]:02X}{rom[0x7FFE]:02X}")