Source code for pyctr.type.sdtitle

# This file is a part of pyctr.
#
# Copyright (c) 2017-2023 Ian Burgwin
# This file is licensed under The MIT License (MIT).
# You can find the full license text in LICENSE in the root of this project.

from enum import IntEnum
from os import fsdecode
from pathlib import Path, PurePosixPath
from typing import TYPE_CHECKING
from weakref import WeakSet

from fs import open_fs
from fs.base import FS
from fs.osfs import OSFS
from fs.path import dirname as fs_dirname, join as fs_join, basename as fs_basename

from ..common import PyCTRError
from ..crypto import add_seed
from .ncch import NCCHReader
from .tmd import TitleMetadataReader

if TYPE_CHECKING:
    from pathlib import PurePath
    from typing import BinaryIO

    from ..common import FilePath
    from .ncch import NCCHReader
    from .sd import SDFilesystem
    from .tmd import ContentChunkRecord


[docs] class SDTitleError(PyCTRError): """Generic error for SD Title operations."""
[docs] class SDTitleSection(IntEnum): TitleMetadata = -1 """Contains information about all the possible contents.""" Application = 0 """Main application CXI.""" Manual = 1 """Manual CFA. It has a RomFS with a single "Manual.bcma" file inside.""" DownloadPlayChild = 2 """ Download Play Child CFA. It has a RomFS with CIA files that are sent to other Nintendo 3DS systems using Download Play. Most games only contain one. """
[docs] class SDTitleReader: """ Reads the contents of files installed on the SD card inside "Nintendo 3DS". By default, this only works with contents that do not use SD encryption (i.e. tmd and contents are plaintext). To read contents currently encrypted on an SD card, :class:`~.SDFilesystem` is needed, and provides a method to easily open a title's contents. (NYI) Only NCCH contents are supported. SRL (DSiWare) contents are currently ignored. :param file: A path to a tmd file. All the contents should be in the same directory. :param fs: An :meth:`FS` object or an `FS URL <https://docs.pyfilesystem.org/en/latest/openers.html>`. :param case_insensitive: Use case-insensitive paths for the RomFS of each NCCH container. :param dev: Use devunit keys. :param seed: Seed to use. This is a quick way to add a seed using :func:`~.seeddb.add_seed`. :param load_contents: Load each partition with :class:`~.NCCHReader`. :param sdfs: :class:`~.SDFilesystem` object to use, if opening contents that are currently encrypted. Usually this should not be set directly, instead open the title through :class:`~.SDFilesystem`. :param sd_id1: ID1 to use if opened through :class:`~.SDFilesystem`. """ __slots__ = ( '_base_files', '_open_files', 'available_sections', 'closed', 'content_info', 'contents', 'sd_id1', 'sdfs', 'tmd', 'fs' ) available_sections: 'list[SDTitleSection | int]' """A list of sections available, including contents, ticket, and title metadata.""" closed: bool """`True` if the reader is closed.""" contents: 'dict[int, NCCHReader]' """A `dict` of :class:`~.NCCHReader` objects for each active NCCH content.""" content_info: 'list[ContentChunkRecord]' """ A list of :class:`~.ContentChunkRecord` objects for each content found in the directory at the time of object initialization. """ tmd: TitleMetadataReader """The :class:`~.TitleMetadataReader` object with information from the TMD section.""" def __init__(self, file: 'FilePath', *, fs: 'FS' = None, case_insensitive: bool = False, dev: bool = False, seed: bytes = None, load_contents: bool = True, sdfs: 'SDFilesystem' = None, sd_id1: str = None): self.closed = False self.sdfs = sdfs self.sd_id1 = sd_id1 self.contents = {} self.content_info = [] # {section: filepath} self._base_files: dict[SDTitleSection | int, PurePath] = {} # opened files to close if the SDTitleReader is closed # noinspection PyTypeChecker self._open_files: set[BinaryIO] = WeakSet() # public method to see what sections can be accessed self.available_sections = [] if self.sdfs: # custom FS is not supported here (this section is deprecated anyway) file = PurePosixPath(file) title_root = fsdecode(file.parent) fs = OSFS(title_root) file = file.name else: if fs: if not isinstance(fs, FS): fs = open_fs(fs) title_root = fs_dirname(file) file = fs_basename(file) else: # the path being absolute makes Path.parent work reliably file = Path(file).absolute() fs = OSFS(fsdecode(file.parent)) title_root = '/' file = file.name self.fs = fs def add_file(section: 'SDTitleSection | int', path: str): self._base_files[section] = path self.available_sections.append(section) add_file(SDTitleSection.TitleMetadata, fs_join(title_root, file)) with self.open_raw_section(SDTitleSection.TitleMetadata) as tmd: self.tmd = TitleMetadataReader.load(tmd) if seed: add_seed(self.tmd.title_id, seed) for record in self.tmd.chunk_records: # check if the content is a Nintendo DS ROM (SRL) is_srl = record.cindex == 0 and self.tmd.title_id[3:5] == '48' # this should ideally never be uppercase in practice # since the console stores these as lowercase content_file = fs_join(title_root, record.id + '.app') if self.sdfs: if not self.sdfs.isfile(str(content_file), id1=self.sd_id1): # can't find the file, so continue to the next record continue else: if not fs.isfile(content_file): continue self.content_info.append(record) add_file(record.cindex, content_file) # this needs to check how many files are being opened if load_contents and not is_srl: decrypted_file = self.open_raw_section(record.cindex) self.contents[record.cindex] = NCCHReader(decrypted_file, case_insensitive=case_insensitive, dev=dev) def __enter__(self): return self def __exit__(self, exc_type, exc_val, exc_tb): self.close()
[docs] def close(self): """Close the reader.""" if not self.closed: self.closed = True for cindex, content in self.contents.items(): content.close() for f in self._open_files: f.close() self.contents = {} # frozenset can't be modified, so even if I made a mistake this prevents opening files on a closed reader self._open_files = frozenset()
__del__ = close def __repr__(self): info = [('title_id', self.tmd.title_id)] try: info.append(('title_name', repr(self.contents[0].exefs.icon.get_app_title().short_desc))) except KeyError: info.append(('title_name', 'unknown')) info.append(('content_count', len(self.contents))) info_final = " ".join(x + ": " + str(y) for x, y in info) return f'<{type(self).__name__} {info_final}>'
[docs] def open_raw_section(self, section: 'SDTitleSection | int') -> 'BinaryIO': """ Open a raw content for reading. :param section: The content to open. :return: A file-like object that reads from the content. :rtype: io.BufferedIOBase | CTRFileIO """ filepath = self._base_files[section] if self.sdfs: f = self.sdfs.open(str(filepath), 'rb', id1=self.sd_id1) else: f = self.fs.open(filepath, 'rb') self._open_files.add(f) return f