dspatch.py/NDSCard/NDSCard.py
2025-04-27 19:12:29 -07:00

742 lines
23 KiB
Python

import os
from io import BytesIO, BufferedIOBase
from typing import overload, BinaryIO
from collections.abc import MutableSequence
from .Utils import uint_le, u8_le, u16_le, u32_le, align
from .CRC16 import crc16
from .NitroFS import FileAllocationEntry, EntryNameTableEndOfDirectoryEntry
from .NitroFS import EntryNameTableDirectoryEntry, DirectoryTableEntry
from .NitroFS import EntryNameTableEntry, EntryNameTableFileEntry
class NDSCard:
def __init__(self, data: str|int|bytes|BinaryIO|None = None):
"""Read and parse a Nintendo DS ROM. If `data` is an open file object or file descriptor,
this does not close it.
"""
if data is None:
self.header = NDSCardHeader()
self.main_rom = b''
self.sub_rom = b''
self.fnt = NDSCardFNT()
self.main_ovt = NDSCardOVT()
self.sub_ovt = NDSCardOVT()
self.fat = NDSCardFAT()
self.file_data = []
return
if isinstance(data, bytes):
data = BytesIO(data)
elif isinstance(data, str): # file path
with open(data, 'rb') as fp:
data = BytesIO(fp.read())
elif isinstance(data, BufferedIOBase): # open file object
initial_fp = data
initial_pos = data.tell()
data = BytesIO(initial_fp.read())
initial_fp.seek(initial_pos)
del initial_fp, initial_pos
elif isinstance(data, int): # open file descriptor
fp = os.fdopen(data, 'rb')
initial_pos = fp.tell()
data = BytesIO(fp.read())
fp.seek(initial_pos)
del initial_pos
total_rom_size = data.seek(0, os.SEEK_END)
data.seek(0)
length = len(NDSCardHeader())
self.header = NDSCardHeader(data.read(length))
data.seek(self.header.main_rom_offset)
self.main_rom = data.read(self.header.main_size)
if uint_le(data.read(4)) == 0xDEC00621: # Nitro footer
data.seek(-4, os.SEEK_CUR)
length = len(NitroFooter())
self.static_footer = NitroFooter(data.read(length))
data.seek(self.header.sub_rom_offset)
self.sub_rom = data.read(self.header.sub_size)
data.seek(self.header.fnt_offset)
self.fnt = NDSCardFNT(data.read(self.header.fnt_size))
data.seek(self.header.main_ovt_offset)
self.main_ovt = NDSCardOVT(data.read(self.header.main_ovt_size))
data.seek(self.header.sub_ovt_offset)
self.sub_ovt = NDSCardOVT(data.read(self.header.sub_ovt_size))
data.seek(self.header.fat_offset)
self.fat = NDSCardFAT(data.read(self.header.fat_size))
if self.header.banner_offset != 0:
data.seek(self.header.banner_offset)
length = len(NDSCardBanner())
self.banner = NDSCardBanner(data.read(length))
self.file_data = []
for entry in self.fat:
data.seek(entry.start)
self.file_data.append(data.read(entry.end - entry.start))
# RSA signature
if self.header.rom_size+0x88 <= total_rom_size:
data.seek(self.header.rom_size)
rsa_sig = data.read(0x88)
# check whether it's just padding
for byte in rsa_sig:
if byte not in (0xff, 0):
self.rsa_sig = rsa_sig
break
def encode(self, multiboot: bool = False) -> bytes:
out = BytesIO()
# skip the header, and write it afterwards
self.header.header_size = 0x4000
out.write(bytes(0x4000))
# ARM9 ROM
self.header.main_rom_offset = out.tell()
self.header.main_size = len(self.main_rom)
out.write(self.main_rom)
# static footer
try:
out.write(self.static_footer.encode())
except AttributeError:
pass
# main OVT
if self.main_ovt:
# alignment
pos = out.tell()
out.write(bytes(align(pos, 0x200) - pos))
self.header.main_ovt_offset = out.tell()
length = len(NDSCardOVT.OverlayTableEntry())
self.header.main_ovt_size = len(self.main_ovt) * length
out.write(self.main_ovt.encode())
for overlay in self.main_ovt:
pos = out.tell()
out.write(bytes(align(pos, 0x200) - pos))
self.fat[overlay.file_id].start = out.tell()
self.fat[overlay.file_id].end = out.tell() + len(self.file_data[overlay.file_id])
out.write(self.file_data[overlay.file_id])
else:
self.header.main_ovt_offset = 0
self.header.main_ovt_size = 0
pos = out.tell()
out.write(b'\xff' * (align(pos, 0x200) - pos))
# ARM7 ROM
self.header.sub_rom_offset = out.tell()
self.header.sub_size = len(self.sub_rom)
out.write(self.sub_rom)
# sub OVT
if self.sub_ovt:
pos = out.tell()
out.write(bytes(align(pos, 0x200) - pos))
self.header.sub_ovt_offset = out.tell()
length = len(NDSCardOVT.OverlayTableEntry())
self.header.sub_ovt_size = len(self.sub_ovt) * length
out.write(self.sub_ovt.encode())
for overlay in self.sub_ovt:
pos = out.tell()
out.write(bytes(align(pos, 0x200) - pos))
self.fat[overlay.file_id].start = out.tell()
self.fat[overlay.file_id].end = out.tell() + len(self.file_data[overlay.file_id])
out.write(self.file_data[overlay.file_id])
else:
self.header.sub_ovt_offset = 0
self.header.sub_ovt_size = 0
pos = out.tell()
out.write(b'\xff' * (align(pos, 0x200) - pos))
# FNT
self.header.fnt_offset = out.tell()
out.write(self.fnt.encode())
self.header.fnt_size = out.tell() - self.header.fnt_offset
pos = out.tell()
out.write(b'\xff' * (align(pos, 0x200) - pos))
# FAT
self.header.fat_offset = out.tell()
self.header.fat_size = len(self.fat) * 8
# skip the FAT, and write it after writing file data
out.write(bytes(self.header.fat_size))
# banner
if hasattr(self, 'banner'):
pos = out.tell()
out.write(b'\xff' * (align(pos, 0x200) - pos))
self.header.banner_offset = out.tell()
out.write(self.banner.encode())
else:
self.header.banner_offset = 0
# files
for i in range(
self.header.main_ovt_size//32 + self.header.sub_ovt_size//32,
len(self.file_data)
):
pos = out.tell()
out.write(b'\xff' * (align(pos, 0x200) - pos))
self.fat[i].start = out.tell()
self.fat[i].end = out.tell() + len(self.file_data[i])
out.write(self.file_data[i])
pos = out.tell()
self.header.rom_size = pos
out.write(bytes(align(pos, 4) - pos))
# device size
if not multiboot:
capacity_size = self.header.rom_size
capacity_size |= capacity_size >> 16
capacity_size |= capacity_size >> 8
capacity_size |= capacity_size >> 4
capacity_size |= capacity_size >> 2
capacity_size |= capacity_size >> 1
capacity_size += 1
if capacity_size <= 0x20000:
capacity_size = 0x20000
capacity = -18
while capacity_size != 0:
capacity_size >>= 1
capacity += 1
self.header.device_size = 0 if capacity < 0 else capacity
else:
self.header.device_size = 0x0b
# RSA
try:
out.write(self.rsa_sig)
except AttributeError:
pass
# write FAT
out.seek(self.header.fat_offset)
out.write(self.fat.encode())
# write header
out.seek(0)
out.write(self.header.encode())
out.seek(0)
return out.read()
__bytes__ = encode
def __len__(self):
return len(self.encode())
class NDSCardHeader:
def __init__(self, data: bytes|None = None):
if data is None:
return
self.game_title = data[:0xc].decode('ascii', errors='surrogateescape').rstrip('\0')
self.game_code = data[0xc:0x10].decode('ascii', errors='surrogateescape').rstrip('\0')
self.maker_code = data[0x10:0x12].decode('ascii', errors='surrogateescape').rstrip('\0')
self.product_id = data[0x12:0x13]
self.device_type = data[0x13:0x14]
self.device_size = data[0x14]
self.reserved_a = data[0x15:0x1d]
self.region = data[0x1d:0x1e]
self.game_version = data[0x1e]
self.property = data[0x1f:0x20]
self.main_rom_offset = uint_le(data[0x20:0x24])
self.main_entry_address = uint_le(data[0x24:0x28])
self.main_ram_address = uint_le(data[0x28:0x2c])
self.main_size = uint_le(data[0x2c:0x30])
self.sub_rom_offset = uint_le(data[0x30:0x34])
self.sub_entry_address = uint_le(data[0x34:0x38])
self.sub_ram_address = uint_le(data[0x38:0x3c])
self.sub_size = uint_le(data[0x3c:0x40])
self.fnt_offset = uint_le(data[0x40:0x44])
self.fnt_size = uint_le(data[0x44:0x48])
self.fat_offset = uint_le(data[0x48:0x4c])
self.fat_size = uint_le(data[0x4c:0x50])
self.main_ovt_offset = uint_le(data[0x50:0x54])
self.main_ovt_size = uint_le(data[0x54:0x58])
self.sub_ovt_offset = uint_le(data[0x58:0x5c])
self.sub_ovt_size = uint_le(data[0x5c:0x60])
self.cmd_settings = data[0x60:0x64]
self.key1_cmd_settings = data[0x64:0x68]
self.banner_offset = uint_le(data[0x68:0x6c])
self.secure_crc = uint_le(data[0x6c:0x6e])
self.secure_delay = uint_le(data[0x6e:0x70])
self.main_autoload_done = uint_le(data[0x70:0x74])
self.sub_autoload_done = uint_le(data[0x74:0x78])
self.secure_disable = data[0x78:0x80]
self.rom_size = uint_le(data[0x80:0x84])
self.header_size = uint_le(data[0x84:0x88])
self.reserved_b = data[0x88:0x94]
self.nand_rom_offset = uint_le(data[0x94:0x96])
self.nand_rw_offset = uint_le(data[0x96:0x98])
self.reserved_c = data[0x98:0xc0]
self.logo_data = data[0xc0:0x15c]
self.logo_crc = uint_le(data[0x15c:0x15e])
self.header_crc = uint_le(data[0x15e:0x160])
def encode(self) -> bytes:
self.logo_crc = crc16(self.logo_data)
header_data = b''.join([
self.game_title.ljust(0xc, '\0').encode('ascii', errors='surrogateescape'),
self.game_code.ljust(4, '\0').encode('ascii', errors='surrogateescape'),
self.maker_code.ljust(2, '\0').encode('ascii', errors='surrogateescape'),
self.product_id,
self.device_type,
u8_le(self.device_size),
self.reserved_a,
self.region,
u8_le(self.game_version),
self.property,
u32_le(self.main_rom_offset),
u32_le(self.main_entry_address),
u32_le(self.main_ram_address),
u32_le(self.main_size),
u32_le(self.sub_rom_offset),
u32_le(self.sub_entry_address),
u32_le(self.sub_ram_address),
u32_le(self.sub_size),
u32_le(self.fnt_offset),
u32_le(self.fnt_size),
u32_le(self.fat_offset),
u32_le(self.fat_size),
u32_le(self.main_ovt_offset),
u32_le(self.main_ovt_size),
u32_le(self.sub_ovt_offset),
u32_le(self.sub_ovt_size),
self.cmd_settings,
self.key1_cmd_settings,
u32_le(self.banner_offset),
u16_le(self.secure_crc),
u16_le(self.secure_delay),
u32_le(self.main_autoload_done),
u32_le(self.sub_autoload_done),
self.secure_disable,
u32_le(self.rom_size),
u32_le(self.header_size),
self.reserved_b,
u16_le(self.nand_rom_offset),
u16_le(self.nand_rw_offset),
self.reserved_c,
self.logo_data,
u16_le(self.logo_crc)
])
self.header_crc = crc16(header_data)
return b''.join([
header_data,
u16_le(self.header_crc),
bytes(0x1000 - 0x160) # padding
])
__bytes__ = encode
def __len__(self):
return 0x1000
class NitroFooter:
def __init__(self, data: bytes|None = None):
if data is None:
self.nitro_code = 0
self.module_params_offset = 0
self.unknown = 0
return
self.nitro_code = uint_le(data[:4])
self.module_params_offset = uint_le(data[4:8])
self.unknown = uint_le(data[8:12])
def encode(self) -> bytes:
return b''.join([
u32_le(self.nitro_code),
u32_le(self.module_params_offset),
u32_le(self.unknown)
])
__bytes__ = encode
def __len__(self):
return 12
class NDSCardFNT:
def __init__(self, data: bytes|None = None):
if data is None:
self.directory_table = NDSCardFNT.DirectoryTable()
self.entry_name_table = NDSCardFNT.EntryNameTable()
return
num_dirs = uint_le(data[6:8])
length = len(DirectoryTableEntry())
self.directory_table = NDSCardFNT.DirectoryTable(data[:num_dirs*length])
ent_offset = num_dirs * len(DirectoryTableEntry())
self.entry_name_table = NDSCardFNT.EntryNameTable(data[ent_offset:], num_dirs)
def encode(self) -> bytes:
return b''.join([self.directory_table.encode(), self.entry_name_table.encode()])
__bytes__ = encode
def __len__(self):
return len(self.encode())
class DirectoryTable(MutableSequence):
def __init__(self, data: bytes|None = None):
self.entries: list[DirectoryTableEntry] = []
if data is None:
return
length = len(DirectoryTableEntry())
self.entries.append(DirectoryTableEntry(data[:length]))
# the parent ID of the first directory table entry is
# the total number of directories, including itself
num_dirs = self.entries[0].parent_id
for i in range(1, num_dirs):
entry_data = data[i*length : (i*length)+length]
self.entries.append(DirectoryTableEntry(entry_data))
def encode(self) -> bytes:
return b''.join(entry.encode() for entry in self.entries)
__bytes__ = encode
def __getitem__(self, index):
return self.entries[index]
def __setitem__(self, index, value):
self.entries[index] = value
def __delitem__(self, index):
del self.entries[index]
def __len__(self):
return len(self.entries)
def insert(self, index, value):
self.entries.insert(index, value)
class EntryNameTable(MutableSequence):
@overload
def __init__(self, data: bytes, num_dirs: int, /):
...
@overload
def __init__(self, data: None = None, /):
...
def __init__(self, *args):
self.entries: list[list[EntryNameTableEntry]] = []
if not args or args[0] is None:
return
data: bytes = args[0]
num_dirs: int = args[1]
offset = 0
isdir = 0x80
end_entries = 0
entries_in_dir = []
while end_entries < num_dirs:
entry_name_length = data[offset]
if entry_name_length == 0: # end entry
entries_in_dir.append(EntryNameTableEndOfDirectoryEntry())
self.entries.append(entries_in_dir)
entries_in_dir = []
end_entries += 1
offset += 1
elif entry_name_length < isdir: # file entry
entries_in_dir.append(
EntryNameTableFileEntry(data[offset : offset+entry_name_length+1])
)
offset += entry_name_length + 1
else: # directory entry
entries_in_dir.append(EntryNameTableDirectoryEntry(
data[offset : offset+(entry_name_length ^ isdir)+3]
))
offset += (entry_name_length ^ isdir) + 3
def encode(self) -> bytes:
return b''.join(
entry.encode()
for entries in self.entries
for entry in entries
)
__bytes__ = encode
def __getitem__(self, index):
return self.entries[index]
def __setitem__(self, index, value):
self.entries[index] = value
def __delitem__(self, index):
del self.entries[index]
def __len__(self):
return len(self.entries)
def insert(self, index, value):
self.entries.insert(index, value)
class NDSCardOVT(MutableSequence):
def __init__(self, data: bytes|None = None):
self.entries: list[NDSCardOVT.OverlayTableEntry] = []
if data is None:
return
length = len(NDSCardOVT.OverlayTableEntry())
for i in range(len(data) // length):
self.entries.append(data[i*length : i*length+length])
def encode(self) -> bytes:
return b''.join(entry.encode() for entry in self.entries)
__bytes__ = encode
def __getitem__(self, index):
return self.entries[index]
def __setitem__(self, index, value):
self.entries[index] = value
def __delitem__(self, index):
del self.entries[index]
def __len__(self):
return len(self.entries)
def __bool__(self):
return bool(self.entries)
def insert(self, index, value):
self.entries.insert(index, value)
class OverlayTableEntry:
def __init__(self, data: bytes|None = None):
if data is None:
self.id = 0
self.ram_address = 0
self.ram_size = 0
self.bss_size = 0
self.static_init_start = 0
self.static_init_end = 0
self.file_id = 0
self.flags = NDSCardOVT.OverlayTableEntry.Flags()
return
self.id = uint_le(data[:0x4])
self.ram_address = uint_le(data[0x4:0x8])
self.ram_size = uint_le(data[0x8:0xc])
self.bss_size = uint_le(data[0xc:0x10])
self.static_init_start = uint_le(data[0x10:0x14])
self.static_init_end = uint_le(data[0x14:0x18])
self.file_id = uint_le(data[0x18:0x1c])
self.flags = NDSCardOVT.OverlayTableEntry.Flags(data[0x1c:0x20])
def encode(self) -> bytes:
return b''.join([
u32_le(self.id),
u32_le(self.ram_address),
u32_le(self.ram_size),
u32_le(self.bss_size),
u32_le(self.static_init_start),
u32_le(self.static_init_end),
u32_le(self.file_id),
self.flags.encode()
])
__bytes__ = encode
def __len__(self):
return 32
class Flags:
def __init__(self, data: bytes|None = None):
if data is None:
self.compressed = 0
self.authentication_code = 0
return
data = uint_le(data[:4])
self.compressed = data & 0x00ffffff
self.authentication_code = (data >> 24) & 0xff
def encode(self) -> bytes:
return u32_le(
0
| (self.compressed & 0x00ffffff)
| ((self.authentication_code & 0xff) << 24)
)
__bytes__ = encode
class NDSCardFAT(MutableSequence):
def __init__(self, data: bytes|None = None):
self.entries: list[FileAllocationEntry] = []
if data is None:
return
length = len(FileAllocationEntry())
self.entries.extend(
FileAllocationEntry(data[i : i+length])
for i in range(0, len(data), length)
)
def encode(self) -> bytes:
return b''.join(entry.encode() for entry in self.entries)
__bytes__ = encode
def __getitem__(self, index):
return self.entries[index]
def __setitem__(self, index, value):
self.entries[index] = value
def __delitem__(self, index):
del self.entries[index]
def __len__(self):
return len(self.entries)
def __bool__(self):
return bool(self.entries)
def insert(self, index, value):
self.entries.insert(index, value)
class NDSCardBanner:
def __init__(self, data: bytes|None = None):
if data is None:
self.header = NDSCardBanner.BannerHeader()
self.banner = NDSCardBanner.BannerV1()
return
header_length = len(NDSCardBanner.BannerHeader())
banner_length = len(NDSCardBanner.BannerV1())
self.header = NDSCardBanner.BannerHeader(data[:header_length])
self.banner = NDSCardBanner.BannerV1(data[header_length : banner_length+header_length])
def encode(self) -> bytes:
self.header.crc16_v1 = crc16(self.banner.encode())
return b''.join([self.header.encode(), self.banner.encode()])
__bytes__ = encode
def __len__(self):
return 832
class BannerHeader:
def __init__(self, data: bytes|None = None):
if data is None:
self.version = 1
self.crc16_v1 = 0
self.reserved = bytes(28)
return
self.version = uint_le(data[0:2])
self.crc16_v1 = uint_le(data[2:4])
self.reserved = data[4:32]
def encode(self) -> bytes:
return b''.join([u16_le(self.version), u16_le(self.crc16_v1), self.reserved])
__bytes__ = encode
def __len__(self):
return 32
class BannerV1:
image_size = 512
palette_size = 32
game_name_size = 256
def __init__(self, data: bytes|None = None):
if data is None:
self.image = bytes(self.image_size)
self.palette = bytes(self.palette_size)
self.game_name = [''] * 6
return
offset = 0
self.image = data[offset : offset+self.image_size]
offset += self.image_size
self.palette = data[offset : offset+self.palette_size]
offset += self.palette_size
self.game_name = []
for _ in range(6):
name = data[offset : offset+self.game_name_size].decode('utf_16_le').rstrip('\0')
self.game_name.append(name)
offset += self.game_name_size
def encode(self) -> bytes:
return b''.join([
self.image,
self.palette,
b''.join(
name.ljust(self.game_name_size//2, '\0').encode('utf_16_le')
for name in self.game_name
)
])
__bytes__ = encode
def __len__(self):
return 800