From 8cd7c840a7261db759d4b21ea420efaa6f676768 Mon Sep 17 00:00:00 2001 From: Fish Date: Fri, 22 May 2026 22:02:53 +0000 Subject: [PATCH] Add an N64 backend. --- cle/__init__.py | 2 + cle/backends/__init__.py | 2 + cle/backends/n64.py | 136 +++++++++++++++++++++++++++++++++++++++ tests/test_n64.py | 83 ++++++++++++++++++++++++ 4 files changed, 223 insertions(+) create mode 100644 cle/backends/n64.py create mode 100644 tests/test_n64.py diff --git a/cle/__init__.py b/cle/__init__.py index c38bda02..081a4e54 100644 --- a/cle/__init__.py +++ b/cle/__init__.py @@ -14,6 +14,7 @@ ALL_BACKENDS, CGC, ELF, + N64, PE, TE, XBE, @@ -101,6 +102,7 @@ "MachO", "MetaELF", "Minidump", + "N64", "NamedRegion", "Region", "Regions", diff --git a/cle/backends/__init__.py b/cle/backends/__init__.py index 05a330e4..ea867dd6 100644 --- a/cle/backends/__init__.py +++ b/cle/backends/__init__.py @@ -12,6 +12,7 @@ from .java.soot import Soot from .macho import MachO from .minidump import Minidump +from .n64 import N64 from .named_region import NamedRegion from .pe import PE, PEStubs from .region import Region, Section, Segment @@ -47,6 +48,7 @@ "SRec", "Minidump", "MachO", + "N64", "NamedRegion", "Jar", "Apk", diff --git a/cle/backends/n64.py b/cle/backends/n64.py new file mode 100644 index 00000000..91d3b5ad --- /dev/null +++ b/cle/backends/n64.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +import logging +import struct + +import archinfo + +from cle.errors import CLEError + +from .backend import Backend, register_backend +from .region import Segment + +log = logging.getLogger(name=__name__) + +__all__ = ("N64",) + + +# z64 (big-endian) magic. Stored in the first 4 bytes of the ROM: 80 37 12 40. +Z64_MAGIC = b"\x80\x37\x12\x40" + +# IPL3 (game-specific boot code) is copied by IPL2 from cart ROM offset 0x40 into +# the RSP DMEM at this virtual address, where it executes from. +BOOTCODE_VADDR = 0xA4000040 +BOOTCODE_FILE_OFFSET = 0x40 +BOOTCODE_SIZE = 0x1000 - 0x40 # 0xFC0 bytes + +# Game code starts at ROM file offset 0x1000. +GAMECODE_FILE_OFFSET = 0x1000 + +HEADER_SIZE = 0x40 + + +class N64(Backend): + """ + Loader for Nintendo 64 .z64 ROM images (big-endian). + + The ROM has a 0x40-byte header followed by 0xFC0 bytes of IPL3 boot code + (file offsets 0x40..0x1000). The remainder of the file is the game program, + which IPL3 DMAs into RAM starting at the entry PC found at header offset + 0x08. This loader maps the game code at that entry PC. If ``skip_bootcode`` + is False (the default), the IPL3 boot code is also mapped at its + execution address 0xA4000040 (RSP DMEM). + """ + + is_default = True + + def __init__(self, *args, skip_bootcode: bool = False, **kwargs): + """ + :param skip_bootcode: If True, do not map the IPL3 boot code segment. + """ + super().__init__(*args, **kwargs) + self.set_load_args(skip_bootcode=skip_bootcode) + self.set_arch(archinfo.arch_from_id("mips32")) + self.os = "n64" + + self._binary_stream.seek(0, 2) + file_size = self._binary_stream.tell() + if file_size < HEADER_SIZE: + raise CLEError(f"z64 ROM is too small: {file_size} bytes") + + self._binary_stream.seek(0) + header = self._binary_stream.read(HEADER_SIZE) + if not header.startswith(Z64_MAGIC): + raise CLEError("Not a z64 ROM (missing 80 37 12 40 magic)") + + # All header fields are big-endian. + ( + self.pi_register, + self.clock_rate, + self.entry_pc, + self.release, + self.crc1, + self.crc2, + ) = struct.unpack(">IIIIII", header[0x00:0x18]) + self.image_name = header[0x20:0x34].decode("ascii", errors="replace").rstrip("\x00 ") + self.cartridge_id = header[0x3C:0x3E] + self.country_code = header[0x3E:0x3F] + self.version = header[0x3F] + + self._entry = self.entry_pc + + # Read the game code: everything from file offset 0x1000 to EOF. + if file_size <= GAMECODE_FILE_OFFSET: + raise CLEError(f"z64 ROM has no game code (size {file_size} <= 0x1000)") + self._binary_stream.seek(GAMECODE_FILE_OFFSET) + game_bytes = self._binary_stream.read(file_size - GAMECODE_FILE_OFFSET) + game_size = len(game_bytes) + + # mapped/linked base = entry PC so add_backer offsets are non-negative. + self.mapped_base = self.linked_base = self.entry_pc + + # Game code segment at entry_pc. + self.memory.add_backer(0, game_bytes) + self.segments.append(Segment(GAMECODE_FILE_OFFSET, self.entry_pc, game_size, game_size)) + + self._min_addr = self.entry_pc + self._max_addr = self.entry_pc + game_size - 1 + + # Boot code segment at 0xA4000040 (RSP DMEM). + if not skip_bootcode: + bootcode_bytes = header[BOOTCODE_FILE_OFFSET:] + self._read_bootcode_tail(file_size) + bootcode_size = len(bootcode_bytes) + if bootcode_size > 0: + self.memory.add_backer(BOOTCODE_VADDR - self.linked_base, bootcode_bytes) + self.segments.append(Segment(BOOTCODE_FILE_OFFSET, BOOTCODE_VADDR, bootcode_size, bootcode_size)) + self._max_addr = max(self._max_addr, BOOTCODE_VADDR + bootcode_size - 1) + + def _read_bootcode_tail(self, file_size: int) -> bytes: + """Read the portion of IPL3 past the first 0x40 bytes of the header.""" + end = min(GAMECODE_FILE_OFFSET, file_size) + if end <= HEADER_SIZE: + return b"" + self._binary_stream.seek(HEADER_SIZE) + return self._binary_stream.read(end - HEADER_SIZE) + + @staticmethod + def is_compatible(stream): + stream.seek(0) + magic = stream.read(4) + stream.seek(0) + return magic == Z64_MAGIC + + @property + def min_addr(self): + return self._min_addr + + @property + def max_addr(self): + return self._max_addr + + @classmethod + def check_compatibility(cls, spec, obj): # pylint: disable=unused-argument + return True + + +register_backend("n64", N64) diff --git a/tests/test_n64.py b/tests/test_n64.py new file mode 100644 index 00000000..b733acc5 --- /dev/null +++ b/tests/test_n64.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import io +import os +import unittest + +import cle + +TEST_BASE = os.path.join(os.path.dirname(os.path.realpath(__file__)), os.path.join("..", "..", "binaries")) +FIXTURE = os.path.join(TEST_BASE, "tests", "mips", "n64", "test.z64") + +ENTRY = 0x80040000 +GAME_SIZE = 0x1000 +BOOTCODE_VADDR = 0xA4000040 +BOOTCODE_SIZE = 0xFC0 + + +class TestN64Loader(unittest.TestCase): + def test_loads_z64_rom(self): + ld = cle.Loader(FIXTURE, auto_load_libs=False) + obj = ld.main_object + + assert isinstance(obj, cle.N64) + assert obj.os == "n64" + assert obj.arch.name == "MIPS32" + assert obj.arch.memory_endness == "Iend_BE" + + assert obj.entry == ENTRY + assert obj.mapped_base == ENTRY + assert obj.linked_base == ENTRY + assert obj.min_addr == ENTRY + + # Header fields parsed correctly. + assert obj.pi_register == 0x80371240 + assert obj.entry_pc == ENTRY + assert obj.crc1 == 0xDEADBEEF + assert obj.crc2 == 0xCAFEBABE + assert obj.image_name == "CLE TEST ROM" + assert obj.cartridge_id == b"NT" + assert obj.country_code == b"E" + assert obj.version == 0x00 + + # Game code is mapped at the entry, and contains the MIPS instructions + # we baked into the fixture: nop, jr $ra, nop, nop. + assert obj.contains_addr(ENTRY) + assert ld.memory.load(ENTRY, 16) == b"\x00\x00\x00\x00\x03\xe0\x00\x08\x00\x00\x00\x00\x00\x00\x00\x00" + + # Default loader maps the IPL3 bootcode as a second segment. + assert obj.contains_addr(BOOTCODE_VADDR) + assert ld.memory.load(BOOTCODE_VADDR, 4) == b"\xaa\xaa\xaa\xaa" + assert obj.max_addr == BOOTCODE_VADDR + BOOTCODE_SIZE - 1 + + # We should have two segments: game code + bootcode. + seg_vaddrs = sorted(s.vaddr for s in obj.segments) + assert seg_vaddrs == [ENTRY, BOOTCODE_VADDR] + + def test_skip_bootcode(self): + ld = cle.Loader( + FIXTURE, + auto_load_libs=False, + main_opts={"backend": "n64", "skip_bootcode": True}, + ) + obj = ld.main_object + + assert isinstance(obj, cle.N64) + # Only the game-code segment should be present. + assert len(obj.segments) == 1 + assert obj.segments[0].vaddr == ENTRY + assert obj.max_addr == ENTRY + GAME_SIZE - 1 + # Bootcode address should not be backed by memory. + assert not obj.contains_addr(BOOTCODE_VADDR) + + def test_is_compatible_detects_z64_magic(self): + with open(FIXTURE, "rb") as f: + assert cle.N64.is_compatible(f) is True + + def test_is_compatible_rejects_non_z64(self): + # ELF magic should not be recognized as z64. + assert cle.N64.is_compatible(io.BytesIO(b"\x7fELF" + b"\x00" * 100)) is False + + +if __name__ == "__main__": + unittest.main()