Source code for pyctr.type.smdh

# 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 struct import pack
from types import MappingProxyType
from typing import TYPE_CHECKING, NamedTuple

try:
    from PIL import Image
    from itertools import chain
except ModuleNotFoundError:
    # Pillow not installed
    Image = None

from ..common import PyCTRError, get_fs_file_object

if TYPE_CHECKING:
    from typing import BinaryIO, Mapping

    from fs.base import FS

    from ..common import FilePath

    RGBTuple = tuple[int, int, int]

SMDH_SIZE = 0x36C0

region_names = (
    'Japanese',
    'English',
    'French',
    'German',
    'Italian',
    'Spanish',
    'Simplified Chinese',
    'Korean',
    'Dutch',
    'Portuguese',
    'Russian',
    'Traditional Chinese',
)

# the order of the SMDH names to check. the difference here is that English is put before Japanese.
_region_order_check = (
    'English',
    'Japanese',
    'French',
    'German',
    'Italian',
    'Spanish',
    'Simplified Chinese',
    'Korean',
    'Dutch',
    'Portuguese',
    'Russian',
    'Traditional Chinese',
)


[docs] class SMDHError(PyCTRError): """Generic exception for SMDH operations."""
[docs] class InvalidSMDHError(SMDHError): """Invalid SMDH contents."""
class AppTitle(NamedTuple): short_desc: str long_desc: str publisher: str @classmethod def from_bytes(cls, app_title_raw: bytes): return cls(short_desc=app_title_raw[0:0x80].decode('utf-16le').strip('\0'), long_desc=app_title_raw[0x80:0x180].decode('utf-16le').strip('\0'), publisher=app_title_raw[0x180:0x200].decode('utf-16le').strip('\0')) def __bytes__(self): return b''.join(( self.short_desc.encode('utf-16le').ljust(0x80, b'\0'), self.long_desc.encode('utf-16le').ljust(0x100, b'\0'), self.publisher.encode('utf-16le').ljust(0x80, b'\0'), )) class SMDHRegionLockout(NamedTuple): Japan: bool NorthAmerica: bool Europe: bool Australia: bool China: bool Korea: bool Taiwan: bool RegionFree: bool @classmethod def from_bytes(cls, region_lockout_bytes: bytes): region_lockouts = int.from_bytes(region_lockout_bytes, 'little') return cls(Japan=bool(region_lockouts & 0x1), NorthAmerica=bool(region_lockouts & 0x2), Europe=bool(region_lockouts & 0x4), Australia=bool(region_lockouts & 0x8), China=bool(region_lockouts & 0x10), Korea=bool(region_lockouts & 0x20), Taiwan=bool(region_lockouts & 0x40), RegionFree=(region_lockouts == 0x7FFFFFFF)) class SMDHFlags(NamedTuple): Visible: bool """Icon is visible at the HOME Menu""" AutoBoot: bool """Auto-boot this game card title (no effect for SD titles)""" Allow3D: bool """Title uses 3D (this is only used for a Parental Controls alert, it does not actually enable/disable 3D)""" RequireEULA: bool """Require accepting the EULA before being launched from the HOME Menu""" AutoSave: bool """Title auto-saves on exit (this means there will not be a prompt to save when attempting to close)""" ExtendedBanner: bool """Title uses an extended banner""" RatingRequired: bool """Region-specific game rating required""" SaveData: bool """Title uses save data (this will prompt the user that unsaved data will be lost, unless AutoSave is set)""" RecordUsage: bool """Application usage is recorded (when not set, the icon is not stored in the icon cache)""" NoSaveBackups: bool """Disable Save-Data Backup""" New3DS: bool """Exclusive to New Nintendo 3DS""" @classmethod def from_bytes(cls, flag_bytes: bytes): flags = int.from_bytes(flag_bytes, 'little') return cls(Visible=bool(flags & 0x1), AutoBoot=bool(flags & 0x2), Allow3D=bool(flags & 0x4), RequireEULA=bool(flags & 0x8), AutoSave=bool(flags & 0x10), ExtendedBanner=bool(flags & 0x20), RatingRequired=bool(flags & 0x40), SaveData=bool(flags & 0x80), RecordUsage=bool(flags & 0x100), NoSaveBackups=bool(flags & 0x400), New3DS=bool(flags & 0x1000)) # Based on: # https://github.com/Steveice10/FBI/blob/c6d92d86b27aaef784d1ecb4103e1346fb0f8a12/source/core/screen.c#L211-L221 def next_pow_2(i: int): i -= 1 i |= i >> 1 i |= i >> 2 i |= i >> 4 i |= i >> 8 i |= i >> 16 i += 1 return i def rgb565_to_rgb888_tuple(data: bytes) -> 'RGBTuple': n = int.from_bytes(data, 'little') r = (((n >> 11) & 0x1F) * 0xFF // 0x1F) & 0xFF g = (((n >> 5) & 0x3F) * 0xFF // 0x3F) & 0xFF b = ((n & 0x1F) * 0xFF // 0x1F) & 0xFF return r, g, b def rgb565_to_rgb888(data: bytes): return pack('>BBB', *rgb565_to_rgb888_tuple(data)) # Based on: # https://github.com/Steveice10/FBI/blob/c6d92d86b27aaef784d1ecb4103e1346fb0f8a12/source/core/screen.c#L305-L323 def load_tiled_rgb565_to_array(data: bytes, width: int, height: int) -> 'list[list[RGBTuple]]': pixel_size = len(data) // width // height pixels = [] for y in range(height): line = [] pixels.append(line) for x in range(width): pixel_offset = ((((y >> 3) * (width >> 3) + (x >> 3)) << 6) + ((x & 1) | ((y & 1) << 1) | ((x & 2) << 1) | ((y & 2) << 2) | ((x & 4) << 2) | ((y & 4) << 3))) * pixel_size pixel = rgb565_to_rgb888_tuple(data[pixel_offset:pixel_offset + pixel_size]) line.append(pixel) return pixels # if Pillow is installed if Image: def rgb888_array_to_image(pixel_array: 'list[list[RGBTuple]]', width: int, height: int): final_data = bytes(chain.from_iterable(chain.from_iterable(pixel_array))) img = Image.frombytes('RGB', (width, height), final_data) return img def load_tiled_rgb565(data: bytes, width: int, height: int): pixel_array = load_tiled_rgb565_to_array(data, width, height) return rgb888_array_to_image(pixel_array, width, height)
[docs] class SMDH: """ Class for 3DS SMDH. https://www.3dbrew.org/wiki/SMDH """ __slots__ = ('flags', 'icon_large', 'icon_large_array', 'icon_small', 'icon_small_array', 'names', 'region_lockout') # TODO: support other settings def __init__(self, names: 'dict[str, AppTitle]', icon_small_array: 'list[list[RGBTuple]]', icon_large_array: 'list[list[RGBTuple]]', flags: SMDHFlags, region_lockout: SMDHRegionLockout): self.names: Mapping[str, AppTitle] = MappingProxyType({n: names.get(n, None) for n in region_names}) self.icon_small_array = icon_small_array self.icon_large_array = icon_large_array self.flags = flags self.region_lockout = region_lockout # if Pillow is installed if Image: self.icon_small = rgb888_array_to_image(self.icon_small_array, 24, 24) self.icon_large = rgb888_array_to_image(self.icon_large_array, 48, 48) else: self.icon_small = None self.icon_large = None def __repr__(self): return f'<{type(self).__name__} title: {self.get_app_title().short_desc}>'
[docs] def get_app_title(self, language: 'str | tuple[str, ...]' = _region_order_check) -> 'AppTitle | None': if isinstance(language, str): language = (language,) for l in language: apptitle = self.names[l] if apptitle: return apptitle # if, for some reason, it fails to return... return AppTitle('unknown', 'unknown', 'unknown')
[docs] @classmethod def load(cls, fp: 'BinaryIO') -> 'SMDH': """Load an SMDH from a file-like object.""" smdh = fp.read(SMDH_SIZE) if len(smdh) != SMDH_SIZE: raise InvalidSMDHError(f'invalid size (expected: {SMDH_SIZE:#6x}, got: {len(smdh):#6x}') if smdh[0:4] != b'SMDH': raise InvalidSMDHError('SMDH magic not found') app_structs = smdh[8:0x2008] names: dict[str, AppTitle] = {} # due to region_names only being 12 elements, this will only process 12. the other 4 are unused. for app_title, region in zip((app_structs[x:x + 0x200] for x in range(0, 0x2000, 0x200)), region_names): names[region] = AppTitle.from_bytes(app_title) icon_raw_small = smdh[0x2040:0x24C0] icon_raw_large = smdh[0x24C0:0x36C0] # This is assuming icon data is RGB565, but 3dbrew says other formats are possible. Though every known example # uses RGB565 and there doesn't seem to be a way to tell which one is being used. icon_small_array = load_tiled_rgb565_to_array(icon_raw_small, 24, 24) icon_large_array = load_tiled_rgb565_to_array(icon_raw_large, 48, 48) flags_raw = smdh[0x2028:0x202C] flags = SMDHFlags.from_bytes(flags_raw) region_lockout_raw = smdh[0x2018:0x201C] region_lockout = SMDHRegionLockout.from_bytes(region_lockout_raw) return cls(names, icon_small_array, icon_large_array, flags, region_lockout)
[docs] @classmethod def from_file(cls, fn: 'FilePath', *, fs: 'FS | None' = None) -> 'SMDH': with get_fs_file_object(fn, fs)[0] as f: return cls.load(f)