Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cle/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
ALL_BACKENDS,
CGC,
ELF,
N64,
PE,
TE,
XBE,
Expand Down Expand Up @@ -101,6 +102,7 @@
"MachO",
"MetaELF",
"Minidump",
"N64",
"NamedRegion",
"Region",
"Regions",
Expand Down
2 changes: 2 additions & 0 deletions cle/backends/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -47,6 +48,7 @@
"SRec",
"Minidump",
"MachO",
"N64",
"NamedRegion",
"Jar",
"Apk",
Expand Down
136 changes: 136 additions & 0 deletions cle/backends/n64.py
Original file line number Diff line number Diff line change
@@ -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)
83 changes: 83 additions & 0 deletions tests/test_n64.py
Original file line number Diff line number Diff line change
@@ -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()
Loading