9 Commits

Author SHA1 Message Date
374358711b Beginning libWiiPy refactors
No major functional changes have been made so far other than a couple of things being shifted between files, but a lot of bad code has been rewritten to hopefully make libWiiPy maintainable into the future.
2025-12-16 23:14:33 -05:00
ce5d118de1 Create cert.sys during EmuNAND title installation if not found 2025-07-12 13:03:42 -04:00
6d38df9133 Type __init()__ functions, clears all mypy issues 2025-05-27 13:57:39 -04:00
2ca2ff1f44 Fix all mypy errors 2025-05-27 13:46:21 -04:00
79ab33c18a Added support for progress callbacks in NUS download functions 2025-05-24 23:38:55 -04:00
e06bb39f4c Properly create/update uid.sys during EmuNAND title installs 2025-04-18 13:54:28 -04:00
8269a0db98 (Title) Add check to make sure Ticket data exist before decrypting content 2025-04-17 16:31:16 -04:00
8adbef26b1 Updated README to reflect features since v0.5.0 2025-02-10 16:26:45 -05:00
5dde9f7835 Changed how the Title ID is handled in Tickets, stubbed out NUS endpoint validation 2025-02-10 13:36:39 -05:00
32 changed files with 532 additions and 346 deletions

View File

@@ -2,7 +2,7 @@
# libWiiPy # libWiiPy
libWiiPy is a modern Python 3 library for handling the various files and formats found on the Wii. It aims to be simple to use, well maintained, and offer as many features as reasonably possible in one library, so that a newly-written Python program could do 100% of its Wii-related work with just one library. It also aims to be fully cross-platform, so that any tools written with it can also be cross-platform. libWiiPy is a modern Python 3 library for handling the various files and formats found on the Wii. It aims to be simple to use, well maintained, and offer as many features as reasonably possible in one library, so that a newly-written Python program could do 100% of its Wii-related work with just one library. It also aims to be fully cross-platform, so that any tools written with it can also be cross-platform.
libWiiPy is inspired by [libWiiSharp](https://github.com/TheShadowEevee/libWiiSharp), which was originally created by `Leathl` and is now maintained by [@TheShadowEevee](https://github.com/TheShadowEevee). If you're looking for a Wii library that isn't in Python, then go check it out! libWiiPy is inspired by [libWiiSharp](https://github.com/TheShadowEevee/libWiiSharp), which was originally created by `Leathl` and is now maintained by [@TheShadowEevee](https://github.com/TheShadowEevee).
# Features # Features
@@ -10,14 +10,18 @@ This list will expand as libWiiPy is developed, but these features are currently
- TMD and Ticket parsing/editing (`.tmd`, `.tik`) - TMD and Ticket parsing/editing (`.tmd`, `.tik`)
- Title parsing/editing, including content encryption/decryption (both retail and development) - Title parsing/editing, including content encryption/decryption (both retail and development)
- WAD file parsing/editing (`.wad`) - WAD file parsing/editing (`.wad`)
- Downloading titles from the NUS - Downloading titles and their components from the NUS
- Certificate, TMD, and Ticket signature verification
- Packing and unpacking U8 archives (`.app`, `.arc`) - Packing and unpacking U8 archives (`.app`, `.arc`)
- Decompressing ASH files (`.ash`, both the standard variants and the variants found in My Pokémon Ranch) - Decompressing ASH files (`.ash`, both the standard variants and the variants found in My Pokémon Ranch)
- Compressing/Decompressing LZ77-compressed files
- IOS patching - IOS patching
- NAND-related functionality: - NAND-related functionality:
- EmuNAND title management (currently requires an existing EmuNAND) - EmuNAND title management (currently requires an existing EmuNAND)
- `content.map` parsing/editing - `content.map` parsing/editing
- `setting.txt` parsing/editing
- `uid.sys` parsing/editing - `uid.sys` parsing/editing
- Limited channel banner parsing/editing
- Assorted miscellaneous features used to make the other core features possible - Assorted miscellaneous features used to make the other core features possible
For a more detailed look at what's available in libWiiPy, check out our [API docs](https://ninjacheetah.github.io/libWiiPy). For a more detailed look at what's available in libWiiPy, check out our [API docs](https://ninjacheetah.github.io/libWiiPy).
@@ -68,6 +72,3 @@ Thank you to all of the contributors to the documentation on the WiiBrew pages t
### One additional special thanks to [@DamiDoop](https://github.com/DamiDoop)! ### One additional special thanks to [@DamiDoop](https://github.com/DamiDoop)!
She made the very cool banner you can see at the top of this README, and has also helped greatly with my sanity throughout debugging this library. She made the very cool banner you can see at the top of this README, and has also helped greatly with my sanity throughout debugging this library.
**Note:** While libWiiPy is directly inspired by libWiiSharp and aims to have feature parity with it, no code from either libWiiSharp or Wii.py was used in the making of this library. All code is original and is written by [@NinjaCheetah](https://github.com/NinjaCheetah), [@rvtr](https://github.com/rvtr), and any other GitHub contributors.

View File

@@ -17,7 +17,13 @@ release = 'main'
# -- General configuration --------------------------------------------------- # -- General configuration ---------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
extensions = ['myst_parser', 'sphinx.ext.napoleon', 'sphinx_copybutton', 'sphinx_tippy', 'sphinx_design'] extensions = [
'myst_parser',
'sphinx.ext.napoleon',
'sphinx_copybutton',
'sphinx_tippy',
'sphinx_design'
]
templates_path = ['_templates'] templates_path = ['_templates']
exclude_patterns = ["Thumbs.db", ".DS_Store"] exclude_patterns = ["Thumbs.db", ".DS_Store"]

View File

@@ -11,4 +11,5 @@ The `libWiiPy.title.nus` module provides support for downloading digital Wii tit
:members: :members:
:undoc-members: :undoc-members:
:show-inheritance: :show-inheritance:
:special-members: __call__
``` ```

View File

@@ -16,8 +16,9 @@ The `libWiiPy.title` package contains modules for interacting with Wii titles. T
| [libWiiPy.title.ticket](/title/ticket) | Provides support for parsing and editing Tickets used for content decryption | | [libWiiPy.title.ticket](/title/ticket) | Provides support for parsing and editing Tickets used for content decryption |
| [libWiiPy.title.title](/title/title.title) | Provides high-level support for parsing and editing an entire title with the context of each component | | [libWiiPy.title.title](/title/title.title) | Provides high-level support for parsing and editing an entire title with the context of each component |
| [libWiiPy.title.tmd](/title/tmd) | Provides support for parsing and editing TMDs (Title Metadata) | | [libWiiPy.title.tmd](/title/tmd) | Provides support for parsing and editing TMDs (Title Metadata) |
| [libWiiPy.title.util](/title/util) | Provides some simple utility functions relating to titles |
| [libWiiPy.title.wad](/title/wad) | Provides support for parsing and editing WAD files, allowing you to load each component into the other available classes | | [libWiiPy.title.wad](/title/wad) | Provides support for parsing and editing WAD files, allowing you to load each component into the other available classes |
| [libWiiPy.title.types](/title/types) | Provides shared types used across the title module. |
| [libWiiPy.title.versions](/title/versions) | Provides utility functions for converting the format that a title's version is in. |
## Full Package Contents ## Full Package Contents
@@ -33,6 +34,7 @@ The `libWiiPy.title` package contains modules for interacting with Wii titles. T
/title/ticket /title/ticket
/title/title.title /title/title.title
/title/tmd /title/tmd
/title/util
/title/wad /title/wad
/title/types
/title/versions
``` ```

View File

@@ -0,0 +1,14 @@
# libWiiPy.title.types Module
## Description
The `libWiiPy.title.types` module provides shared types used across the title module.
## Module Contents
```{eval-rst}
.. automodule:: libWiiPy.title.types
:members:
:undoc-members:
:show-inheritance:
```

View File

@@ -1,14 +0,0 @@
# libWiiPy.title.util Module
## Description
The `libWiiPy.title.util` module provides common utility functions internally. It is not designed to be used directly.
## Module Contents
```{eval-rst}
.. automodule:: libWiiPy.title.util
:members:
:undoc-members:
:show-inheritance:
```

View File

@@ -0,0 +1,14 @@
# libWiiPy.title.versions Module
## Description
The `libWiiPy.title.versions` module provides functions for converting the format that a title's version is in.
## Module Contents
```{eval-rst}
.. automodule:: libWiiPy.title.versions
:members:
:undoc-members:
:show-inheritance:
```

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "libWiiPy" name = "libWiiPy"
version = "0.6.0" version = "1.0.0"
authors = [ authors = [
{ name="NinjaCheetah", email="ninjacheetah@ncxprogramming.com" }, { name="NinjaCheetah", email="ninjacheetah@ncxprogramming.com" },
{ name="Lillian Skinner", email="lillian@randommeaninglesscharacters.com" } { name="Lillian Skinner", email="lillian@randommeaninglesscharacters.com" }
@@ -13,7 +13,7 @@ classifiers = [
# 3 - Alpha # 3 - Alpha
# 4 - Beta # 4 - Beta
# 5 - Production/Stable # 5 - Production/Stable
"Development Status :: 4 - Beta", "Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers", "Intended Audience :: Developers",
"License :: OSI Approved :: MIT License", "License :: OSI Approved :: MIT License",
"Operating System :: OS Independent", "Operating System :: OS Independent",
@@ -23,7 +23,8 @@ classifiers = [
] ]
dependencies = [ dependencies = [
"pycryptodome", "pycryptodome",
"requests" "requests",
"types-requests"
] ]
keywords = ["Wii", "wii"] keywords = ["Wii", "wii"]

View File

@@ -1,6 +1,7 @@
build build
pycryptodome pycryptodome
requests requests
types-requests
sphinx sphinx
sphinx-book-theme sphinx-book-theme
myst-parser myst-parser

View File

@@ -8,10 +8,11 @@
# See <link pending> for details about the ASH compression format. # See <link pending> for details about the ASH compression format.
import io import io
from dataclasses import dataclass as _dataclass from dataclasses import dataclass
from typing import List
@_dataclass @dataclass
class _ASHBitReader: class _ASHBitReader:
""" """
An _ASHBitReader class used to parse individual words in an ASH file. Private class used by the ASH module. An _ASHBitReader class used to parse individual words in an ASH file. Private class used by the ASH module.
@@ -93,7 +94,7 @@ def _ash_bit_reader_read_bits(bit_reader: _ASHBitReader, num_bits: int):
return bits return bits
def _ash_read_tree(bit_reader: _ASHBitReader, width: int, left_tree: [int], right_tree: [int]): def _ash_read_tree(bit_reader: _ASHBitReader, width: int, left_tree: List[int], right_tree: List[int]):
# Read either the symbol or distance tree from the ASH file, and return the root of that tree. # Read either the symbol or distance tree from the ASH file, and return the root of that tree.
work = [0] * (2 * (1 << width)) work = [0] * (2 * (1 << width))
work_pos = 0 work_pos = 0

View File

@@ -5,7 +5,7 @@
import io import io
from dataclasses import dataclass as _dataclass from dataclasses import dataclass as _dataclass
from typing import List as _List from typing import List, Tuple
_LZ_MIN_DISTANCE = 0x01 # Minimum distance for each reference. _LZ_MIN_DISTANCE = 0x01 # Minimum distance for each reference.
@@ -21,7 +21,7 @@ class _LZNode:
weight: int = 0 weight: int = 0
def _compress_compare_bytes(buffer: _List[int], offset1: int, offset2: int, abs_len_max: int) -> int: def _compress_compare_bytes(buffer: List[int], offset1: int, offset2: int, abs_len_max: int) -> int:
# Compare bytes up to the maximum length we can match. Start by comparing the first 3 bytes, since that's the # Compare bytes up to the maximum length we can match. Start by comparing the first 3 bytes, since that's the
# minimum match length and this allows for a more optimized early exit. # minimum match length and this allows for a more optimized early exit.
num_matched = 0 num_matched = 0
@@ -32,7 +32,7 @@ def _compress_compare_bytes(buffer: _List[int], offset1: int, offset2: int, abs_
return num_matched return num_matched
def _compress_search_matches_optimized(buffer: _List[int], pos: int) -> (int, int): def _compress_search_matches_optimized(buffer: List[int], pos: int) -> Tuple[int, int]:
bytes_left = len(buffer) - pos bytes_left = len(buffer) - pos
global _LZ_MAX_DISTANCE, _LZ_MIN_LENGTH, _LZ_MAX_LENGTH, _LZ_MIN_DISTANCE global _LZ_MAX_DISTANCE, _LZ_MIN_LENGTH, _LZ_MAX_LENGTH, _LZ_MIN_DISTANCE
# Default to only looking back 4096 bytes, unless we've moved fewer than 4096 bytes, in which case we should # Default to only looking back 4096 bytes, unless we've moved fewer than 4096 bytes, in which case we should
@@ -54,7 +54,7 @@ def _compress_search_matches_optimized(buffer: _List[int], pos: int) -> (int, in
return biggest_match, biggest_match_pos return biggest_match, biggest_match_pos
def _compress_search_matches_greedy(buffer: _List[int], pos: int) -> (int, int): def _compress_search_matches_greedy(buffer: List[int], pos: int) -> Tuple[int, int]:
# Finds and returns the first valid match, rather that finding the best one. # Finds and returns the first valid match, rather that finding the best one.
bytes_left = len(buffer) - pos bytes_left = len(buffer) - pos
global _LZ_MAX_DISTANCE, _LZ_MAX_LENGTH, _LZ_MIN_DISTANCE global _LZ_MAX_DISTANCE, _LZ_MAX_LENGTH, _LZ_MIN_DISTANCE

View File

@@ -57,7 +57,7 @@ class U8Archive:
imet_header: IMETHeader imet_header: IMETHeader
The IMET header of the U8 archive, if one exists. Otherwise, an empty IMETHeader object. The IMET header of the U8 archive, if one exists. Otherwise, an empty IMETHeader object.
""" """
def __init__(self): def __init__(self) -> None:
self.u8_magic = b'' self.u8_magic = b''
self.u8_node_list: List[_U8Node] = [] # All the nodes in the header of a U8 file. self.u8_node_list: List[_U8Node] = [] # All the nodes in the header of a U8 file.
self.file_name_list: List[str] = [] self.file_name_list: List[str] = []
@@ -68,16 +68,16 @@ class U8Archive:
self.root_node: _U8Node = _U8Node(0, 0, 0, 0) self.root_node: _U8Node = _U8Node(0, 0, 0, 0)
self.imet_header: IMETHeader = IMETHeader() self.imet_header: IMETHeader = IMETHeader()
def load(self, u8_data: bytes) -> None: def load(self, u8: bytes) -> None:
""" """
Loads raw U8 data into a new U8 object. This allows for extracting the file and updating its contents. Loads raw U8 data into a new U8 object. This allows for extracting the file and updating its contents.
Parameters Parameters
---------- ----------
u8_data : bytes u8 : bytes
The data for the U8 file to load. The data for the U8 file to load.
""" """
with io.BytesIO(u8_data) as u8_data: with io.BytesIO(u8) as u8_data:
# Read the first 4 bytes of the file to ensure that it's a U8 archive. # Read the first 4 bytes of the file to ensure that it's a U8 archive.
u8_data.seek(0x0) u8_data.seek(0x0)
self.u8_magic = u8_data.read(4) self.u8_magic = u8_data.read(4)
@@ -126,7 +126,7 @@ class U8Archive:
# Seek back before the root node so that it gets read with all the rest. # Seek back before the root node so that it gets read with all the rest.
u8_data.seek(u8_data.tell() - 12) u8_data.seek(u8_data.tell() - 12)
# Iterate over the number of nodes that the root node lists. # Iterate over the number of nodes that the root node lists.
for node in range(root_node_size): for _ in range(root_node_size):
node_type = int.from_bytes(u8_data.read(1)) node_type = int.from_bytes(u8_data.read(1))
node_name_offset = int.from_bytes(u8_data.read(3)) node_name_offset = int.from_bytes(u8_data.read(3))
node_data_offset = int.from_bytes(u8_data.read(4)) node_data_offset = int.from_bytes(u8_data.read(4))
@@ -160,7 +160,7 @@ class U8Archive:
# This is 0 because the header size DOES NOT include the initial 32 bytes describing the file. # This is 0 because the header size DOES NOT include the initial 32 bytes describing the file.
header_size = 0 header_size = 0
# Add 12 bytes for each node, since that's how many bytes each one is made up of. # Add 12 bytes for each node, since that's how many bytes each one is made up of.
for node in range(len(self.u8_node_list)): for _ in range(len(self.u8_node_list)):
header_size += 12 header_size += 12
# Add the number of bytes used for each file/folder name in the string table. # Add the number of bytes used for each file/folder name in the string table.
for file_name in self.file_name_list: for file_name in self.file_name_list:
@@ -170,13 +170,13 @@ class U8Archive:
# Adjust all nodes to place file data in the same order as the nodes. Why isn't it already like this? # Adjust all nodes to place file data in the same order as the nodes. Why isn't it already like this?
current_data_offset = data_offset current_data_offset = data_offset
current_name_offset = 0 current_name_offset = 0
for node in range(len(self.u8_node_list)): for idx in range(len(self.u8_node_list)):
if self.u8_node_list[node].type == 0: if self.u8_node_list[idx].type == 0:
self.u8_node_list[node].data_offset = _align_value(current_data_offset, 32) self.u8_node_list[idx].data_offset = _align_value(current_data_offset, 32)
current_data_offset += _align_value(self.u8_node_list[node].size, 32) current_data_offset += _align_value(self.u8_node_list[idx].size, 32)
# Calculate the name offsets, including the extra 1 for the NULL byte at the end of each name. # Calculate the name offsets, including the extra 1 for the NULL byte at the end of each name.
self.u8_node_list[node].name_offset = current_name_offset self.u8_node_list[idx].name_offset = current_name_offset
current_name_offset += len(self.file_name_list[node]) + 1 current_name_offset += len(self.file_name_list[idx]) + 1
# Begin joining all the U8 archive data into bytes. # Begin joining all the U8 archive data into bytes.
u8_data = b'' u8_data = b''
# Magic number. # Magic number.
@@ -300,7 +300,7 @@ def _pack_u8_dir(u8_archive: U8Archive, current_path, node_count, parent_node):
return u8_archive, node_count return u8_archive, node_count
def pack_u8(input_path, generate_imet=False, imet_titles:List[str]=None) -> bytes: def pack_u8(input_path, generate_imet=False, imet_titles:List[str] | None = None) -> bytes:
""" """
Packs the provided file or folder into a new U8 archive, and returns the raw file data for it. Packs the provided file or folder into a new U8 archive, and returns the raw file data for it.
@@ -369,7 +369,7 @@ class IMETHeader:
md5_hash : bytes md5_hash : bytes
MD5 sum of the entire header, with this field being all zeros during the hashing. MD5 sum of the entire header, with this field being all zeros during the hashing.
""" """
def __init__(self): def __init__(self) -> None:
self.magic: str = "" # Should always be "IMET" self.magic: str = "" # Should always be "IMET"
self.header_size: int = 0 # Always 1536? I assumed this would mean something, but it's just the header length. self.header_size: int = 0 # Always 1536? I assumed this would mean something, but it's just the header length.
self.imet_version: int = 0 # Always 3? self.imet_version: int = 0 # Always 3?
@@ -513,13 +513,15 @@ class IMETHeader:
raise ValueError(f"The specified language is not valid!") raise ValueError(f"The specified language is not valid!")
return self.channel_names[target_languages] return self.channel_names[target_languages]
# If multiple channel names were requested. # If multiple channel names were requested.
else: elif type(target_languages) == List:
channel_names = [] channel_names = []
for lang in target_languages: for lang in target_languages:
if lang not in self.LocalizedTitles: if lang not in self.LocalizedTitles:
raise ValueError(f"The specified language at index {target_languages.index(lang)} is not valid!") raise ValueError(f"The specified language at index {target_languages.index(lang)} is not valid!")
channel_names.append(self.channel_names[lang]) channel_names.append(self.channel_names[lang])
return channel_names return channel_names
else:
raise TypeError("Target languages must be type int or List[int]!")
def set_channel_names(self, channel_names: Tuple[int, str] | List[Tuple[int, str]]) -> None: def set_channel_names(self, channel_names: Tuple[int, str] | List[Tuple[int, str]]) -> None:
""" """
@@ -544,7 +546,7 @@ class IMETHeader:
f"42 characters!") f"42 characters!")
self.channel_names[channel_names[0]] = channel_names[1] self.channel_names[channel_names[0]] = channel_names[1]
# If a list of channel names was provided. # If a list of channel names was provided.
else: elif type(channel_names) == list:
for name in channel_names: for name in channel_names:
if name[0] not in self.LocalizedTitles: if name[0] not in self.LocalizedTitles:
raise ValueError(f"The target language \"{name[0]}\" for the name at index {channel_names.index(name)} " raise ValueError(f"The target language \"{name[0]}\" for the name at index {channel_names.index(name)} "
@@ -553,3 +555,5 @@ class IMETHeader:
raise ValueError(f"The channel name \"{name[1]}\" at index {channel_names.index(name)} is too long! " raise ValueError(f"The channel name \"{name[1]}\" at index {channel_names.index(name)} is too long! "
f"Channel names cannot exceed 42 characters!") f"Channel names cannot exceed 42 characters!")
self.channel_names[name[0]] = name[1] self.channel_names[name[0]] = name[1]
else:
raise TypeError("Channel names must be type Tuple[int, str] or List[Tuple[int, str]]!")

65
src/libWiiPy/constants.py Normal file
View File

@@ -0,0 +1,65 @@
# "constants.py" from libWiiPy by NinjaCheetah & Contributors
# https://github.com/NinjaCheetah/libWiiPy
#
# This file defines constant values referenced across the library.
_WII_MENU_VERSIONS = {
"Prelaunch": [0, 1, 2],
"1.0J": 64,
"1.0U": 33,
"1.0E": 34,
"2.0J": 128,
"2.0U": 97,
"2.0E": 130,
"2.1E": 162,
"2.2J": 192,
"2.2U": 193,
"2.2E": 194,
"3.0J": 224,
"3.0U": 225,
"3.0E": 226,
"3.1J": 256,
"3.1U": 257,
"3.1E": 258,
"3.2J": 288,
"3.2U": 289,
"3.2E": 290,
"3.3J": 352,
"3.3U": 353,
"3.3E": 354,
"3.3K": 326,
"3.4J": 384,
"3.4U": 385,
"3.4E": 386,
"3.5K": 390,
"4.0J": 416,
"4.0U": 417,
"4.0E": 418,
"4.1J": 448,
"4.1U": 449,
"4.1E": 450,
"4.1K": 454,
"4.2J": 480,
"4.2U": 481,
"4.2E": 482,
"4.2K": 486,
"4.3J": 512,
"4.3U": 513,
"4.3E": 514,
"4.3K": 518,
"4.3U-Mini": 4609,
"4.3E-Mini": 4610
}
_VWII_MENU_VERSIONS = {
"vWii-1.0.0J": 512,
"vWii-1.0.0U": 513,
"vWii-1.0.0E": 514,
"vWii-4.0.0J": 544,
"vWii-4.0.0U": 545,
"vWii-4.0.0E": 546,
"vWii-5.2.0J": 608,
"vWii-5.2.0U": 609,
"vWii-5.2.0E": 610,
}

View File

@@ -14,16 +14,10 @@ class IMD5Header:
An IMD5 header is always 32 bytes long. An IMD5 header is always 32 bytes long.
Attributes :ivar magic: Magic number for the header, should be "IMD5".
---------- :ivar file_size: The size of the file this header precedes.
magic : str :ivar zeros: 8 bytes of zero padding.
Magic number for the header, should be "IMD5". :ivar md5_hash: The MD5 hash of the file this header precedes.
file_size : int
The size of the file this header precedes.
zeros : int
8 bytes of zero padding.
md5_hash : bytes
The MD5 hash of the file this header precedes.
""" """
magic: str # Should always be "IMD5" magic: str # Should always be "IMD5"
file_size: int file_size: int

View File

@@ -7,7 +7,8 @@ import os
import pathlib import pathlib
import shutil import shutil
from dataclasses import dataclass as _dataclass from dataclasses import dataclass as _dataclass
from typing import List from typing import Callable, List
from ..title.ticket import Ticket from ..title.ticket import Ticket
from ..title.title import Title from ..title.title import Title
from ..title.tmd import TMD from ..title.tmd import TMD
@@ -32,9 +33,9 @@ class EmuNAND:
emunand_root : pathlib.Path emunand_root : pathlib.Path
The path to the EmuNAND root directory. The path to the EmuNAND root directory.
""" """
def __init__(self, emunand_root: str | pathlib.Path, callback: callable = None): def __init__(self, emunand_root: str | pathlib.Path, callback: Callable | None = None):
self.emunand_root = pathlib.Path(emunand_root) self.emunand_root = pathlib.Path(emunand_root)
self.log = callback if callback is not None else None self.log = callback if callback is not None else lambda x: None
self.import_dir = self.emunand_root.joinpath("import") self.import_dir = self.emunand_root.joinpath("import")
self.meta_dir = self.emunand_root.joinpath("meta") self.meta_dir = self.emunand_root.joinpath("meta")
@@ -70,12 +71,14 @@ class EmuNAND:
skip_hash : bool, optional skip_hash : bool, optional
Skip the hash check and install the title regardless of its hashes. Defaults to false. Skip the hash check and install the title regardless of its hashes. Defaults to false.
""" """
self.log(f"[PROGRESS] Starting install of title with Title ID {title.tmd.title_id}...")
# Save the upper and lower portions of the Title ID, because these are used as target install directories. # Save the upper and lower portions of the Title ID, because these are used as target install directories.
tid_upper = title.tmd.title_id[:8] tid_upper = title.tmd.title_id[:8]
tid_lower = title.tmd.title_id[8:] tid_lower = title.tmd.title_id[8:]
# Tickets are installed as <tid_lower>.tik in /ticket/<tid_upper>/ # Tickets are installed as <tid_lower>.tik in /ticket/<tid_upper>/
ticket_dir = self.ticket_dir.joinpath(tid_upper) ticket_dir = self.ticket_dir.joinpath(tid_upper)
self.log(f"[PROGRESS] Installing ticket to \"{ticket_dir}\"...")
ticket_dir.mkdir(exist_ok=True) ticket_dir.mkdir(exist_ok=True)
ticket_dir.joinpath(f"{tid_lower}.tik").write_bytes(title.ticket.dump()) ticket_dir.joinpath(f"{tid_lower}.tik").write_bytes(title.ticket.dump())
@@ -86,19 +89,25 @@ class EmuNAND:
title_dir = title_dir.joinpath(tid_lower) title_dir = title_dir.joinpath(tid_lower)
title_dir.mkdir(exist_ok=True) title_dir.mkdir(exist_ok=True)
content_dir = title_dir.joinpath("content") content_dir = title_dir.joinpath("content")
self.log(f"[PROGRESS] Installing TMD to \"{content_dir}\"...")
if content_dir.exists(): if content_dir.exists():
shutil.rmtree(content_dir) # Clear the content directory so old contents aren't left behind. shutil.rmtree(content_dir) # Clear the content directory so old contents aren't left behind.
content_dir.mkdir(exist_ok=True) content_dir.mkdir(exist_ok=True)
content_dir.joinpath("title.tmd").write_bytes(title.tmd.dump()) content_dir.joinpath("title.tmd").write_bytes(title.tmd.dump())
self.log(f"[PROGRESS] Installing content to \"{content_dir}\"...")
if skip_hash:
self.log("[WARN] Not checking content hashes! Content validity will not be verified.")
for content_file in range(0, title.tmd.num_contents): for content_file in range(0, title.tmd.num_contents):
if title.tmd.content_records[content_file].content_type == 1: if title.tmd.content_records[content_file].content_type == 1:
content_file_name = f"{title.tmd.content_records[content_file].content_id:08X}".lower() content_file_name = f"{title.tmd.content_records[content_file].content_id:08X}".lower()
self.log(f"[PROGRESS] Installing content \"{content_file_name}.app\" to \"{content_dir}\"... ")
content_dir.joinpath(f"{content_file_name}.app").write_bytes( content_dir.joinpath(f"{content_file_name}.app").write_bytes(
title.get_content_by_index(content_file, skip_hash=skip_hash)) title.get_content_by_index(content_file, skip_hash=skip_hash))
title_dir.joinpath("data").mkdir(exist_ok=True) # Empty directory used for save data for the title. title_dir.joinpath("data").mkdir(exist_ok=True) # Empty directory used for save data for the title.
# Shared contents need to be installed to /shared1/, with incremental names determined by /shared1/content.map. # Shared contents need to be installed to /shared1/, with incremental names determined by /shared1/content.map.
content_map_path = self.shared1_dir.joinpath("content.map") content_map_path = self.shared1_dir.joinpath("content.map")
self.log(f"[PROGRESS] Installing shared content to \"{self.shared1_dir}\"...")
content_map = _SharedContentMap() content_map = _SharedContentMap()
existing_hashes = [] existing_hashes = []
if content_map_path.exists(): if content_map_path.exists():
@@ -108,7 +117,10 @@ class EmuNAND:
for content_file in range(0, title.tmd.num_contents): for content_file in range(0, title.tmd.num_contents):
if title.tmd.content_records[content_file].content_type == 32769: if title.tmd.content_records[content_file].content_type == 32769:
if title.tmd.content_records[content_file].content_hash not in existing_hashes: if title.tmd.content_records[content_file].content_hash not in existing_hashes:
self.log(f"[PROGRESS] Adding shared content hash to content.map...")
content_file_name = content_map.add_content(title.tmd.content_records[content_file].content_hash) content_file_name = content_map.add_content(title.tmd.content_records[content_file].content_hash)
self.log(f"[PROGRESS] Installing shared content \"{content_file_name}.app\" to "
f"\"{self.shared1_dir}\"...")
self.shared1_dir.joinpath(f"{content_file_name}.app").write_bytes( self.shared1_dir.joinpath(f"{content_file_name}.app").write_bytes(
title.get_content_by_index(content_file, skip_hash=skip_hash)) title.get_content_by_index(content_file, skip_hash=skip_hash))
self.shared1_dir.joinpath("content.map").write_bytes(content_map.dump()) self.shared1_dir.joinpath("content.map").write_bytes(content_map.dump())
@@ -120,6 +132,7 @@ class EmuNAND:
meta_dir = self.meta_dir.joinpath(tid_upper) meta_dir = self.meta_dir.joinpath(tid_upper)
meta_dir.mkdir(exist_ok=True) meta_dir.mkdir(exist_ok=True)
meta_dir = meta_dir.joinpath(tid_lower) meta_dir = meta_dir.joinpath(tid_lower)
self.log(f"[PROGRESS] Installing meta data to \"{meta_dir}\"...")
meta_dir.mkdir(exist_ok=True) meta_dir.mkdir(exist_ok=True)
meta_dir.joinpath("title.met").write_bytes(title.wad.get_meta_data()) meta_dir.joinpath("title.met").write_bytes(title.wad.get_meta_data())
@@ -127,7 +140,25 @@ class EmuNAND:
uid_sys_path = self.sys_dir.joinpath("uid.sys") uid_sys_path = self.sys_dir.joinpath("uid.sys")
uid_sys = _UidSys() uid_sys = _UidSys()
if not uid_sys_path.exists(): if not uid_sys_path.exists():
self.log("[WARN] uid.sys does not exist! Creating it with the default entry.")
uid_sys.create() uid_sys.create()
else:
uid_sys.load(uid_sys_path.read_bytes())
self.log("[PROGRESS] Adding title to uid.sys and assigning a new UID...")
uid_sys.add(title.tmd.title_id)
uid_sys_path.write_bytes(uid_sys.dump())
# Check for a cert.sys and initialize it using the certs in the WAD if it doesn't exist.
cert_sys_path = self.sys_dir.joinpath("cert.sys")
if not cert_sys_path.exists():
self.log("[WARN] cert.sys does not exist! Creating it using certs from the installed title...")
cert_sys_data = b''
cert_sys_data += title.cert_chain.ticket_cert.dump()
cert_sys_data += title.cert_chain.ca_cert.dump()
cert_sys_data += title.cert_chain.tmd_cert.dump()
cert_sys_path.write_bytes(cert_sys_data)
self.log("[PROGRESS] Completed title installation.")
def uninstall_title(self, tid: str) -> None: def uninstall_title(self, tid: str) -> None:
""" """
@@ -170,12 +201,8 @@ class EmuNAND:
An InstalledTitles object that is used to track a title type and any titles that belong to that type that are An InstalledTitles object that is used to track a title type and any titles that belong to that type that are
installed to an EmuNAND. installed to an EmuNAND.
Attributes :ivar type: The type (Title ID high) of the installed titles.
---------- :ivar titles: The Title ID low of each installed title.
type : str
The type (Title ID high) of the installed titles.
titles : List[str]
The Title ID low of each installed title.
""" """
type: str type: str
titles: List[str] titles: List[str]

View File

@@ -4,10 +4,11 @@
# See https://wiibrew.org/wiki//title/00000001/00000002/data/setting.txt for information about setting.txt. # See https://wiibrew.org/wiki//title/00000001/00000002/data/setting.txt for information about setting.txt.
import io import io
from typing import List
from ..shared import _pad_bytes from ..shared import _pad_bytes
_key = 0x73B5DBFA _KEY = 0x73B5DBFA
class SettingTxt: class SettingTxt:
""" """
@@ -32,7 +33,7 @@ class SettingTxt:
game : str game : str
Another region code, possibly set by the hidden region select channel. Another region code, possibly set by the hidden region select channel.
""" """
def __init__(self): def __init__(self) -> None:
self.area: str = "" self.area: str = ""
self.model: str = "" self.model: str = ""
self.dvd: int = 0 self.dvd: int = 0
@@ -52,17 +53,17 @@ class SettingTxt:
The data of an encrypted setting.txt file. The data of an encrypted setting.txt file.
""" """
with io.BytesIO(setting_txt) as setting_data: with io.BytesIO(setting_txt) as setting_data:
global _key # I still don't actually know what *kind* of encryption this is. global _KEY # I still don't actually know what *kind* of encryption this is.
setting_txt_dec: [int] = [] setting_txt_dec: List[int] = []
for i in range(0, 256): for i in range(0, 256):
setting_txt_dec.append(int.from_bytes(setting_data.read(1)) ^ (_key & 0xff)) setting_txt_dec.append(int.from_bytes(setting_data.read(1)) ^ (_KEY & 0xff))
_key = (_key << 1) | (_key >> 31) _KEY = (_KEY << 1) | (_KEY >> 31)
setting_txt_dec = bytes(setting_txt_dec) setting_txt_bytes = bytes(setting_txt_dec)
try: try:
setting_str = setting_txt_dec.decode('utf-8') setting_str = setting_txt_bytes.decode('utf-8')
except UnicodeDecodeError: except UnicodeDecodeError:
last_newline_pos = setting_txt_dec.rfind(b'\n') # This makes sure we don't try to decode any garbage data. last_newline_pos = setting_txt_bytes.rfind(b'\n') # This makes sure we don't try to decode any garbage data.
setting_str = setting_txt_dec[:last_newline_pos + 1].decode('utf-8') setting_str = setting_txt_bytes[:last_newline_pos + 1].decode('utf-8')
self.load_decrypted(setting_str) self.load_decrypted(setting_str)
def load_decrypted(self, setting_txt: str) -> None: def load_decrypted(self, setting_txt: str) -> None:
@@ -102,15 +103,15 @@ class SettingTxt:
""" """
setting_str = self.dump_decrypted() setting_str = self.dump_decrypted()
setting_txt_dec = setting_str.encode() setting_txt_dec = setting_str.encode()
global _key global _KEY
# This could probably be made more efficient somehow. # This could probably be made more efficient somehow.
setting_txt_enc: [int] = [] setting_txt_enc: List[int] = []
with io.BytesIO(setting_txt_dec) as setting_data: with io.BytesIO(setting_txt_dec) as setting_data:
for i in range(0, len(setting_txt_dec)): for i in range(0, len(setting_txt_dec)):
setting_txt_enc.append(int.from_bytes(setting_data.read(1)) ^ (_key & 0xff)) setting_txt_enc.append(int.from_bytes(setting_data.read(1)) ^ (_KEY & 0xff))
_key = (_key << 1) | (_key >> 31) _KEY = (_KEY << 1) | (_KEY >> 31)
setting_txt_enc = _pad_bytes(bytes(setting_txt_enc), 256) setting_txt_bytes = _pad_bytes(bytes(setting_txt_enc), 256)
return setting_txt_enc return setting_txt_bytes
def dump_decrypted(self) -> str: def dump_decrypted(self) -> str:
""" """

View File

@@ -36,7 +36,7 @@ class UidSys:
The entries stored in the uid.sys file. The entries stored in the uid.sys file.
""" """
def __init__(self): def __init__(self) -> None:
self.uid_entries: List[_UidSysEntry] = [] self.uid_entries: List[_UidSysEntry] = []
def load(self, uid_sys: bytes) -> None: def load(self, uid_sys: bytes) -> None:
@@ -77,7 +77,8 @@ class UidSys:
def add(self, title_id: str | bytes) -> int: def add(self, title_id: str | bytes) -> int:
""" """
Adds a new Title ID to the uid.sys file and returns the UID assigned to that title. Adds a new Title ID to the uid.sys file and returns the UID assigned to that title. The new entry will only
be added if the provided Title ID doesn't already have an assigned UID.
Parameters Parameters
---------- ----------
@@ -90,11 +91,8 @@ class UidSys:
The UID assigned to the new Title ID. The UID assigned to the new Title ID.
""" """
if type(title_id) is bytes: if type(title_id) is bytes:
# This catches the format b'0000000100000002'
if len(title_id) == 16:
title_id_converted = title_id.encode()
# This catches the format b'\x00\x00\x00\x01\x00\x00\x00\x02' # This catches the format b'\x00\x00\x00\x01\x00\x00\x00\x02'
elif len(title_id) == 8: if len(title_id) == 8:
title_id_converted = binascii.hexlify(title_id).decode() title_id_converted = binascii.hexlify(title_id).decode()
# If it isn't one of those lengths, it cannot possibly be valid, so reject it. # If it isn't one of those lengths, it cannot possibly be valid, so reject it.
else: else:
@@ -106,6 +104,11 @@ class UidSys:
title_id_converted = title_id title_id_converted = title_id
else: else:
raise TypeError("Title ID type is not valid! It must be either type str or bytes.") raise TypeError("Title ID type is not valid! It must be either type str or bytes.")
# Ensure this TID hasn't already been assigned a UID. If it has, just exit early and return the UID.
if self.uid_entries.count != 0:
for entry in self.uid_entries:
if entry.title_id == title_id_converted:
return entry.uid
# Generate the new UID by incrementing the current highest UID by 1. # Generate the new UID by incrementing the current highest UID by 1.
try: try:
new_uid = self.uid_entries[-1].uid + 1 new_uid = self.uid_entries[-1].uid + 1

0
src/libWiiPy/py.typed Normal file
View File

View File

@@ -47,69 +47,3 @@ def _pad_bytes(data, alignment=64) -> bytes:
while (len(data) % alignment) != 0: while (len(data) % alignment) != 0:
data += b'\x00' data += b'\x00'
return data return data
def _bitmask(x: int) -> int:
return 1 << x
_wii_menu_versions = {
"Prelaunch": [0, 1, 2],
"1.0J": 64,
"1.0U": 33,
"1.0E": 34,
"2.0J": 128,
"2.0U": 97,
"2.0E": 130,
"2.1E": 162,
"2.2J": 192,
"2.2U": 193,
"2.2E": 194,
"3.0J": 224,
"3.0U": 225,
"3.0E": 226,
"3.1J": 256,
"3.1U": 257,
"3.1E": 258,
"3.2J": 288,
"3.2U": 289,
"3.2E": 290,
"3.3J": 352,
"3.3U": 353,
"3.3E": 354,
"3.3K": 326,
"3.4J": 384,
"3.4U": 385,
"3.4E": 386,
"3.5K": 390,
"4.0J": 416,
"4.0U": 417,
"4.0E": 418,
"4.1J": 448,
"4.1U": 449,
"4.1E": 450,
"4.1K": 454,
"4.2J": 480,
"4.2U": 481,
"4.2E": 482,
"4.2K": 486,
"4.3J": 512,
"4.3U": 513,
"4.3E": 514,
"4.3K": 518,
"4.3U-Mini": 4609,
"4.3E-Mini": 4610
}
_vwii_menu_versions = {
"vWii-1.0.0J": 512,
"vWii-1.0.0U": 513,
"vWii-1.0.0E": 514,
"vWii-4.0.0J": 544,
"vWii-4.0.0U": 545,
"vWii-4.0.0E": 546,
"vWii-5.2.0J": 608,
"vWii-5.2.0U": 609,
"vWii-5.2.0E": 610,
}

View File

@@ -9,5 +9,6 @@ from .nus import *
from .ticket import * from .ticket import *
from .title import * from .title import *
from .tmd import * from .tmd import *
from .util import * from .types import *
from .versions import *
from .wad import * from .wad import *

View File

@@ -5,33 +5,47 @@
import io import io
from enum import IntEnum as _IntEnum from enum import IntEnum as _IntEnum
from ..shared import _align_value, _pad_bytes
from .ticket import Ticket
from .tmd import TMD
from Crypto.Hash import SHA1 from Crypto.Hash import SHA1
from Crypto.PublicKey import RSA from Crypto.PublicKey import RSA
from Crypto.Signature import pkcs1_15 from Crypto.Signature import pkcs1_15
from ..shared import _align_value, _pad_bytes
from .ticket import Ticket
from .tmd import TMD
class CertificateType(_IntEnum): class CertificateType(_IntEnum):
"""
The type of a certificate.
"""
RSA_4096 = 0x00010000 RSA_4096 = 0x00010000
RSA_2048 = 0x00010001 RSA_2048 = 0x00010001
ECC = 0x00010002 ECC = 0x00010002
class CertificateSignatureLength(_IntEnum): class CertificateSignatureLength(_IntEnum):
"""
The length of a certificate's signature.
"""
RSA_4096 = 0x200 RSA_4096 = 0x200
RSA_2048 = 0x100 RSA_2048 = 0x100
ECC = 0x3C ECC = 0x3C
class CertificateKeyType(_IntEnum): class CertificateKeyType(_IntEnum):
"""
The type of key contained in a certificate.
"""
RSA_4096 = 0x00000000 RSA_4096 = 0x00000000
RSA_2048 = 0x00000001 RSA_2048 = 0x00000001
ECC = 0x00000002 ECC = 0x00000002
class CertificateKeyLength(_IntEnum): class CertificateKeyLength(_IntEnum):
"""
The length of the key contained in a certificate.
"""
RSA_4096 = 0x200 RSA_4096 = 0x200
RSA_2048 = 0x100 RSA_2048 = 0x100
ECC = 0x3C ECC = 0x3C
@@ -60,11 +74,11 @@ class Certificate:
pub_key_exponent: int pub_key_exponent: int
The exponent of this certificate's public key. Combined with the modulus to get the full key. The exponent of this certificate's public key. Combined with the modulus to get the full key.
""" """
def __init__(self): def __init__(self) -> None:
self.type: CertificateType | None = None self.type: CertificateType = CertificateType.RSA_4096
self.signature: bytes = b'' self.signature: bytes = b''
self.issuer: str = "" self.issuer: str = ""
self.pub_key_type: CertificateKeyType | None = None self.pub_key_type: CertificateKeyType = CertificateKeyType.RSA_4096
self.child_name: str = "" self.child_name: str = ""
self.pub_key_id: int = 0 self.pub_key_id: int = 0
self.pub_key_modulus: int = 0 self.pub_key_modulus: int = 0
@@ -151,7 +165,7 @@ class CertificateChain:
ticket_cert: Certificate ticket_cert: Certificate
The XS (Ticket) certificate from the chain. The XS (Ticket) certificate from the chain.
""" """
def __init__(self): def __init__(self) -> None:
self.ca_cert: Certificate = Certificate() self.ca_cert: Certificate = Certificate()
self.tmd_cert: Certificate = Certificate() self.tmd_cert: Certificate = Certificate()
self.ticket_cert: Certificate = Certificate() self.ticket_cert: Certificate = Certificate()

View File

@@ -33,13 +33,12 @@ def get_common_key(common_key_index, dev=False) -> bytes:
match common_key_index: match common_key_index:
case 0: case 0:
if dev: if dev:
common_key_bin = binascii.unhexlify(development_key) return binascii.unhexlify(development_key)
else: else:
common_key_bin = binascii.unhexlify(common_key) return binascii.unhexlify(common_key)
case 1: case 1:
common_key_bin = binascii.unhexlify(korean_key) return binascii.unhexlify(korean_key)
case 2: case 2:
common_key_bin = binascii.unhexlify(vwii_key) return binascii.unhexlify(vwii_key)
case _: case _:
common_key_bin = binascii.unhexlify(common_key) return binascii.unhexlify(common_key)
return common_key_bin

View File

@@ -9,7 +9,7 @@ import hashlib
from typing import List from typing import List
from dataclasses import dataclass as _dataclass from dataclasses import dataclass as _dataclass
from enum import IntEnum as _IntEnum from enum import IntEnum as _IntEnum
from ..types import _ContentRecord from .types import ContentRecord
from ..shared import _pad_bytes, _align_value from ..shared import _pad_bytes, _align_value
from .crypto import decrypt_content, encrypt_content from .crypto import decrypt_content, encrypt_content
@@ -28,20 +28,20 @@ class ContentRegion:
Attributes Attributes
---------- ----------
content_records : List[_ContentRecord] content_records : List[ContentRecord]
The content records for the content stored in the region. The content records for the content stored in the region.
num_contents : int num_contents : int
The total number of contents stored in the region. The total number of contents stored in the region.
""" """
def __init__(self): def __init__(self) -> None:
self.content_records: List[_ContentRecord] = [] self.content_records: List[ContentRecord] = []
self.content_region_size: int = 0 # Size of the content region. self.content_region_size: int = 0 # Size of the content region.
self.num_contents: int = 0 # Number of contents in the content region. self.num_contents: int = 0 # Number of contents in the content region.
self.content_start_offsets: List[int] = [0] # The start offsets of each content in the content region. self.content_start_offsets: List[int] = [0] # The start offsets of each content in the content region.
self.content_list: List[bytes] = [] self.content_list: List[bytes] = []
def load(self, content_region: bytes, content_records: List[_ContentRecord]) -> None: def load(self, content_region: bytes, content_records: List[ContentRecord]) -> None:
""" """
Loads the raw content region and builds a list of all the contents. Loads the raw content region and builds a list of all the contents.
@@ -49,7 +49,7 @@ class ContentRegion:
---------- ----------
content_region : bytes content_region : bytes
The raw data for the content region being loaded. The raw data for the content region being loaded.
content_records : list[_ContentRecord] content_records : list[ContentRecord]
A list of ContentRecord objects detailing all contents contained in the region. A list of ContentRecord objects detailing all contents contained in the region.
""" """
self.content_records = content_records self.content_records = content_records
@@ -66,16 +66,16 @@ class ContentRegion:
start_offset += 64 - (content.content_size % 64) start_offset += 64 - (content.content_size % 64)
self.content_start_offsets.append(start_offset) self.content_start_offsets.append(start_offset)
# Build a list of all the encrypted content data. # Build a list of all the encrypted content data.
for content in range(self.num_contents): for idx in range(self.num_contents):
# Seek to the start of the content based on the list of offsets. # Seek to the start of the content based on the list of offsets.
content_region_data.seek(self.content_start_offsets[content]) content_region_data.seek(self.content_start_offsets[idx])
# Calculate the number of bytes we need to read by adding bytes up the nearest multiple of 16 if needed. # Calculate the number of bytes we need to read by adding bytes up the nearest multiple of 16 if needed.
bytes_to_read = self.content_records[content].content_size content_size = self.content_records[idx].content_size
if (bytes_to_read % 16) != 0: if (content_size % 16) != 0:
bytes_to_read += 16 - (bytes_to_read % 16) content_size += 16 - (content_size % 16)
# Read the file based on the size of the content in the associated record, then append that data to # Read the file based on the size of the content in the associated record, then append that data to
# the list of content. # the list of content.
content_enc = content_region_data.read(bytes_to_read) content_enc = content_region_data.read(content_size)
self.content_list.append(content_enc) self.content_list.append(content_enc)
def dump(self) -> tuple[bytes, int]: def dump(self) -> tuple[bytes, int]:
@@ -303,7 +303,7 @@ class ContentRegion:
raise ValueError("Content with an index of " + str(index) + " already exists!") raise ValueError("Content with an index of " + str(index) + " already exists!")
# If we're good, then append all the data and create a new ContentRecord(). # If we're good, then append all the data and create a new ContentRecord().
self.content_list.append(enc_content) self.content_list.append(enc_content)
self.content_records.append(_ContentRecord(cid, index, content_type, content_size, content_hash)) self.content_records.append(ContentRecord(cid, index, content_type, content_size, content_hash))
self.num_contents += 1 self.num_contents += 1
def add_content(self, dec_content: bytes, cid: int, content_type: int, title_key: bytes) -> None: def add_content(self, dec_content: bytes, cid: int, content_type: int, title_key: bytes) -> None:
@@ -336,8 +336,8 @@ class ContentRegion:
enc_content = encrypt_content(dec_content, title_key, index) enc_content = encrypt_content(dec_content, title_key, index)
self.add_enc_content(enc_content, cid, index, content_type, content_size, content_hash) self.add_enc_content(enc_content, cid, index, content_type, content_size, content_hash)
def set_enc_content(self, enc_content: bytes, index: int, content_size: int, content_hash: bytes, cid: int = None, def set_enc_content(self, enc_content: bytes, index: int, content_size: int, content_hash: bytes,
content_type: int = None) -> None: cid: int | None = None, content_type: int | None = None) -> None:
""" """
Sets the content at the provided content index to the provided new encrypted content. The provided hash and Sets the content at the provided content index to the provided new encrypted content. The provided hash and
content size are set in the corresponding content record. A new Content ID or content type can also be content size are set in the corresponding content record. A new Content ID or content type can also be
@@ -373,8 +373,8 @@ class ContentRegion:
self.content_list.append(b'') self.content_list.append(b'')
self.content_list[index] = enc_content self.content_list[index] = enc_content
def set_content(self, dec_content: bytes, index: int, title_key: bytes, cid: int = None, def set_content(self, dec_content: bytes, index: int, title_key: bytes, cid: int | None = None,
content_type: int = None) -> None: content_type: int | None = None) -> None:
""" """
Sets the content at the provided content index to the provided new decrypted content. The hash and content size Sets the content at the provided content index to the provided new decrypted content. The hash and content size
of this content will be generated and then set in the corresponding content record. A new Content ID or content of this content will be generated and then set in the corresponding content record. A new Content ID or content
@@ -525,7 +525,7 @@ class SharedContentMap:
The shared content records stored in content.map. The shared content records stored in content.map.
""" """
def __init__(self): def __init__(self) -> None:
self.shared_records: List[_SharedContentRecord] = [] self.shared_records: List[_SharedContentRecord] = []
def load(self, content_map: bytes) -> None: def load(self, content_map: bytes) -> None:

View File

@@ -10,7 +10,6 @@ from Crypto.Cipher import AES as _AES
def _convert_tid_to_iv(title_id: str | bytes) -> bytes: def _convert_tid_to_iv(title_id: str | bytes) -> bytes:
# Converts a Title ID in various formats into the format required to act as an IV. Private function used by other # Converts a Title ID in various formats into the format required to act as an IV. Private function used by other
# crypto functions. # crypto functions.
title_key_iv = b''
if type(title_id) is bytes: if type(title_id) is bytes:
# This catches the format b'0000000100000002' # This catches the format b'0000000100000002'
if len(title_id) == 16: if len(title_id) == 16:

View File

@@ -20,7 +20,7 @@ class IOSPatcher:
dip_module_index : int dip_module_index : int
The content index that DIP resides in and where DIP patches are applied. -1 if DIP patches are not applied. The content index that DIP resides in and where DIP patches are applied. -1 if DIP patches are not applied.
""" """
def __init__(self): def __init__(self) -> None:
self.title: Title = Title() self.title: Title = Title()
self.es_module_index: int = -1 self.es_module_index: int = -1
self.dip_module_index: int = -1 self.dip_module_index: int = -1

View File

@@ -5,8 +5,8 @@
import requests import requests
#import hashlib #import hashlib
from typing import List from typing import Any, List, Protocol
from urllib.parse import urlparse as _urlparse #from urllib.parse import urlparse as _urlparse
from .title import Title from .title import Title
from .tmd import TMD from .tmd import TMD
from .ticket import Ticket from .ticket import Ticket
@@ -14,13 +14,36 @@ from .ticket import Ticket
_nus_endpoint = ["http://nus.cdn.shop.wii.com/ccs/download/", "http://ccs.cdn.wup.shop.nintendo.net/ccs/download/"] _nus_endpoint = ["http://nus.cdn.shop.wii.com/ccs/download/", "http://ccs.cdn.wup.shop.nintendo.net/ccs/download/"]
def download_title(title_id: str, title_version: int = None, wiiu_endpoint: bool = False, class DownloadCallback(Protocol):
endpoint_override: str = None) -> Title: """
The format of a callable passed to a NUS download function.
"""
def __call__(self, done: int, total: int) -> Any:
"""
This function will be called with the current number of bytes downloaded and the total size of the file being
downloaded.
Parameters
----------
done : int
The number of bytes already downloaded.
total : int
The total size of the file being downloaded.
"""
...
def download_title(title_id: str, title_version: int | None = None, wiiu_endpoint: bool = False,
endpoint_override: str | None = None, progress: DownloadCallback = lambda done, total: None) -> Title:
""" """
Download an entire title and all of its contents, then load the downloaded components into a Title object for Download an entire title and all of its contents, then load the downloaded components into a Title object for
further use. This method is NOT recommended for general use, as it has absolutely no verbosity. It is instead further use. This method is NOT recommended for general use, as it has extremely limited verbosity. It is instead
recommended to call the individual download methods instead to provide more flexibility and output. recommended to call the individual download methods instead to provide more flexibility and output.
Be aware that you will receive fairly vague feedback from this function if you attach a progress callback. The
callback will be connected to each of the individual functions called by this function, but there will be no
indication of which function is currently running, just the progress of its download.
Parameters Parameters
---------- ----------
title_id : str title_id : str
@@ -32,27 +55,34 @@ def download_title(title_id: str, title_version: int = None, wiiu_endpoint: bool
endpoint_override: str, optional endpoint_override: str, optional
A custom endpoint URL to use instead of the standard Wii or Wii U endpoints. Defaults to no override, and if A custom endpoint URL to use instead of the standard Wii or Wii U endpoints. Defaults to no override, and if
set entirely overrides the "wiiu_endpoint" parameter. set entirely overrides the "wiiu_endpoint" parameter.
progress: DownloadCallback, optional
A callback function used to return the progress of the downloads. The provided callable must match the signature
defined in DownloadCallback.
Returns Returns
------- -------
Title Title
A Title object containing all the data from the downloaded title. A Title object containing all the data from the downloaded title.
See Also
--------
libWiiPy.title.nus.DownloadCallback
""" """
# First, create the new title. # First, create the new title.
title = Title() title = Title()
# Download and load the certificate chain, TMD, and Ticket. # Download and load the certificate chain, TMD, and Ticket.
title.load_cert_chain(download_cert_chain(wiiu_endpoint, endpoint_override)) title.load_cert_chain(download_cert_chain(wiiu_endpoint, endpoint_override))
title.load_tmd(download_tmd(title_id, title_version, wiiu_endpoint, endpoint_override)) title.load_tmd(download_tmd(title_id, title_version, wiiu_endpoint, endpoint_override, progress))
title.load_ticket(download_ticket(title_id, wiiu_endpoint, endpoint_override)) title.load_ticket(download_ticket(title_id, wiiu_endpoint, endpoint_override, progress))
# Download all contents # Download all contents
title.load_content_records() title.load_content_records()
title.content.content_list = download_contents(title_id, title.tmd, wiiu_endpoint, endpoint_override) title.content.content_list = download_contents(title_id, title.tmd, wiiu_endpoint, endpoint_override, progress)
# Return the completed title. # Return the completed title.
return title return title
def download_tmd(title_id: str, title_version: int = None, wiiu_endpoint: bool = False, def download_tmd(title_id: str, title_version: int | None = None, wiiu_endpoint: bool = False,
endpoint_override: str = None) -> bytes: endpoint_override: str | None = None, progress: DownloadCallback = lambda done, total: None) -> bytes:
""" """
Downloads the TMD of the Title specified in the object. Will download the latest version by default, or another Downloads the TMD of the Title specified in the object. Will download the latest version by default, or another
version if it was manually specified in the object. version if it was manually specified in the object.
@@ -68,11 +98,18 @@ def download_tmd(title_id: str, title_version: int = None, wiiu_endpoint: bool =
endpoint_override: str, optional endpoint_override: str, optional
A custom endpoint URL to use instead of the standard Wii or Wii U endpoints. Defaults to no override, and if A custom endpoint URL to use instead of the standard Wii or Wii U endpoints. Defaults to no override, and if
set entirely overrides the "wiiu_endpoint" parameter. set entirely overrides the "wiiu_endpoint" parameter.
progress: DownloadCallback, optional
A callback function used to return the progress of the download. The provided callable must match the signature
defined in DownloadCallback.
Returns Returns
------- -------
bytes bytes
The TMD file from the NUS. The TMD file from the NUS.
See Also
--------
libWiiPy.title.nus.DownloadCallback
""" """
# Build the download URL. The structure is download/<TID>/tmd for latest and download/<TID>/tmd.<version> for # Build the download URL. The structure is download/<TID>/tmd for latest and download/<TID>/tmd.<version> for
# when a specific version is requested. # when a specific version is requested.
@@ -89,7 +126,7 @@ def download_tmd(title_id: str, title_version: int = None, wiiu_endpoint: bool =
tmd_url += "." + str(title_version) tmd_url += "." + str(title_version)
# Make the request. # Make the request.
try: try:
tmd_request = requests.get(url=tmd_url, headers={'User-Agent': 'wii libnup/1.0'}, stream=True) response = requests.get(url=tmd_url, headers={'User-Agent': 'wii libnup/1.0'}, stream=True)
except requests.exceptions.ConnectionError: except requests.exceptions.ConnectionError:
if endpoint_override: if endpoint_override:
raise ValueError("A connection could not be made to the NUS endpoint. Please make sure that your endpoint " raise ValueError("A connection could not be made to the NUS endpoint. Please make sure that your endpoint "
@@ -97,11 +134,19 @@ def download_tmd(title_id: str, title_version: int = None, wiiu_endpoint: bool =
else: else:
raise Exception("A connection could not be made to the NUS endpoint. The NUS may be unavailable.") raise Exception("A connection could not be made to the NUS endpoint. The NUS may be unavailable.")
# Handle a 404 if the TID/version doesn't exist. # Handle a 404 if the TID/version doesn't exist.
if tmd_request.status_code != 200: if response.status_code == 404:
raise ValueError("The requested Title ID or TMD version does not exist. Please check the Title ID and Title" raise ValueError("The requested Title ID or TMD version does not exist. Please check the Title ID and Title"
" version and then try again.") " version and then try again.")
# Save the raw TMD. elif response.status_code != 200:
raw_tmd = tmd_request.content raise Exception(f"An unknown error occurred while downloading the TMD. "
f"Got HTTP status code: {response.status_code}")
total_size = int(response.headers["Content-Length"])
progress(0, total_size)
# Stream the TMD's data in chunks so that we can post updates to the callback function (assuming one was supplied).
raw_tmd = b""
for chunk in response.iter_content(512):
raw_tmd += chunk
progress(len(raw_tmd), total_size)
# Use a TMD object to load the data and then return only the actual TMD. # Use a TMD object to load the data and then return only the actual TMD.
tmd_temp = TMD() tmd_temp = TMD()
tmd_temp.load(raw_tmd) tmd_temp.load(raw_tmd)
@@ -109,7 +154,8 @@ def download_tmd(title_id: str, title_version: int = None, wiiu_endpoint: bool =
return tmd return tmd
def download_ticket(title_id: str, wiiu_endpoint: bool = False, endpoint_override: str = None) -> bytes: def download_ticket(title_id: str, wiiu_endpoint: bool = False, endpoint_override: str | None = None,
progress: DownloadCallback = lambda done, total: None) -> bytes:
""" """
Downloads the Ticket of the Title specified in the object. This will only work if the Title ID specified is for Downloads the Ticket of the Title specified in the object. This will only work if the Title ID specified is for
a free title. a free title.
@@ -123,11 +169,18 @@ def download_ticket(title_id: str, wiiu_endpoint: bool = False, endpoint_overrid
endpoint_override: str, optional endpoint_override: str, optional
A custom endpoint URL to use instead of the standard Wii or Wii U endpoints. Defaults to no override, and if A custom endpoint URL to use instead of the standard Wii or Wii U endpoints. Defaults to no override, and if
set entirely overrides the "wiiu_endpoint" parameter. set entirely overrides the "wiiu_endpoint" parameter.
progress: DownloadCallback, optional
A callback function used to return the progress of the download. The provided callable must match the signature
defined in DownloadCallback.
Returns Returns
------- -------
bytes bytes
The Ticket file from the NUS. The Ticket file from the NUS.
See Also
--------
libWiiPy.title.nus.DownloadCallback
""" """
# Build the download URL. The structure is download/<TID>/cetk, and cetk will only exist if this is a free # Build the download URL. The structure is download/<TID>/cetk, and cetk will only exist if this is a free
# title. # title.
@@ -141,18 +194,26 @@ def download_ticket(title_id: str, wiiu_endpoint: bool = False, endpoint_overrid
ticket_url = endpoint_url + title_id + "/cetk" ticket_url = endpoint_url + title_id + "/cetk"
# Make the request. # Make the request.
try: try:
ticket_request = requests.get(url=ticket_url, headers={'User-Agent': 'wii libnup/1.0'}, stream=True) response = requests.get(url=ticket_url, headers={'User-Agent': 'wii libnup/1.0'}, stream=True)
except requests.exceptions.ConnectionError: except requests.exceptions.ConnectionError:
if endpoint_override: if endpoint_override:
raise ValueError("A connection could not be made to the NUS endpoint. Please make sure that your endpoint " raise ValueError("A connection could not be made to the NUS endpoint. Please make sure that your endpoint "
"override is valid.") "override is valid.")
else: else:
raise Exception("A connection could not be made to the NUS endpoint. The NUS may be unavailable.") raise Exception("A connection could not be made to the NUS endpoint. The NUS may be unavailable.")
if ticket_request.status_code != 200: if response.status_code == 404:
raise ValueError("The requested Title ID does not exist, or refers to a non-free title. Tickets can only" raise ValueError("The requested Title ID does not exist, or refers to a non-free title. Tickets can only"
" be downloaded for titles that are free on the NUS.") " be downloaded for titles that are free on the NUS.")
# Save the raw cetk file. elif response.status_code != 200:
cetk = ticket_request.content raise Exception(f"An unknown error occurred while downloading the Ticket. "
f"Got HTTP status code: {response.status_code}")
total_size = int(response.headers["Content-Length"])
progress(0, total_size)
# Stream the Ticket's data just like with the TMD.
cetk = b""
for chunk in response.iter_content(chunk_size=1024):
cetk += chunk
progress(len(cetk), total_size)
# Use a Ticket object to load only the Ticket data from cetk and return it. # Use a Ticket object to load only the Ticket data from cetk and return it.
ticket_temp = Ticket() ticket_temp = Ticket()
ticket_temp.load(cetk) ticket_temp.load(cetk)
@@ -160,7 +221,7 @@ def download_ticket(title_id: str, wiiu_endpoint: bool = False, endpoint_overrid
return ticket return ticket
def download_cert_chain(wiiu_endpoint: bool = False, endpoint_override: str = None) -> bytes: def download_cert_chain(wiiu_endpoint: bool = False, endpoint_override: str | None = None) -> bytes:
""" """
Downloads the signing certificate chain used by all WADs. This uses System Menu 4.3U as the source. Downloads the signing certificate chain used by all WADs. This uses System Menu 4.3U as the source.
@@ -211,8 +272,8 @@ def download_cert_chain(wiiu_endpoint: bool = False, endpoint_override: str = No
return cert_chain return cert_chain
def download_content(title_id: str, content_id: int, wiiu_endpoint: bool = False, def download_content(title_id: str, content_id: int, wiiu_endpoint: bool = False, endpoint_override: str | None = None,
endpoint_override: str = None) -> bytes: progress: DownloadCallback = lambda done, total: None) -> bytes:
""" """
Downloads a specified content for the title specified in the object. Downloads a specified content for the title specified in the object.
@@ -227,16 +288,20 @@ def download_content(title_id: str, content_id: int, wiiu_endpoint: bool = False
endpoint_override: str, optional endpoint_override: str, optional
A custom endpoint URL to use instead of the standard Wii or Wii U endpoints. Defaults to no override, and if A custom endpoint URL to use instead of the standard Wii or Wii U endpoints. Defaults to no override, and if
set entirely overrides the "wiiu_endpoint" parameter. set entirely overrides the "wiiu_endpoint" parameter.
progress: DownloadCallback, optional
A callback function used to return the progress of the download. The provided callable must match the signature
defined in DownloadCallback.
Returns Returns
------- -------
bytes bytes
The downloaded content. The downloaded content.
See Also
--------
libWiiPy.title.nus.DownloadCallback
""" """
# Build the download URL. The structure is download/<TID>/<Content ID>. # Build the download URL. The structure is download/<TID>/<Content ID>.
content_id_hex = hex(content_id)[2:]
if len(content_id_hex) < 2:
content_id_hex = "0" + content_id_hex
if endpoint_override is not None: if endpoint_override is not None:
endpoint_url = _validate_endpoint(endpoint_override) endpoint_url = _validate_endpoint(endpoint_override)
else: else:
@@ -244,26 +309,34 @@ def download_content(title_id: str, content_id: int, wiiu_endpoint: bool = False
endpoint_url = _nus_endpoint[1] endpoint_url = _nus_endpoint[1]
else: else:
endpoint_url = _nus_endpoint[0] endpoint_url = _nus_endpoint[0]
content_url = endpoint_url + title_id + "/000000" + content_id_hex content_url = f"{endpoint_url}{title_id}/{content_id:08X}"
# Make the request. # Make the request.
try: try:
content_request = requests.get(url=content_url, headers={'User-Agent': 'wii libnup/1.0'}, stream=True) response = requests.get(url=content_url, headers={'User-Agent': 'wii libnup/1.0'}, stream=True)
except requests.exceptions.ConnectionError: except requests.exceptions.ConnectionError:
if endpoint_override: if endpoint_override:
raise ValueError("A connection could not be made to the NUS endpoint. Please make sure that your endpoint " raise ValueError("A connection could not be made to the NUS endpoint. Please make sure that your endpoint "
"override is valid.") "override is valid.")
else: else:
raise Exception("A connection could not be made to the NUS endpoint. The NUS may be unavailable.") raise Exception("A connection could not be made to the NUS endpoint. The NUS may be unavailable.")
if content_request.status_code != 200: if response.status_code == 404:
raise ValueError("The requested Title ID does not exist, or an invalid Content ID is present in the" raise ValueError(f"The requested Title ID does not exist, or an invalid Content ID is present in the"
" content records provided.\n Failed while downloading Content ID: 000000" + f" content records provided.\n Failed while downloading Content ID: {content_id:08X}")
content_id_hex) elif response.status_code != 200:
content_data = content_request.content raise Exception(f"An unknown error occurred while downloading the content. "
return content_data f"Got HTTP status code: {response.status_code}")
total_size = int(response.headers["Content-Length"])
progress(0, total_size)
# Stream the content just like the TMD/Ticket.
content = b""
for chunk in response.iter_content(chunk_size=1024):
content += chunk
progress(len(content), total_size)
return content
def download_contents(title_id: str, tmd: TMD, wiiu_endpoint: bool = False, def download_contents(title_id: str, tmd: TMD, wiiu_endpoint: bool = False, endpoint_override: str | None = None,
endpoint_override: str = None) -> List[bytes]: progress: DownloadCallback = lambda done, total: None) -> List[bytes]:
""" """
Downloads all the contents for the title specified in the object. This requires a TMD to already be available Downloads all the contents for the title specified in the object. This requires a TMD to already be available
so that the content records can be accessed. so that the content records can be accessed.
@@ -279,11 +352,18 @@ def download_contents(title_id: str, tmd: TMD, wiiu_endpoint: bool = False,
endpoint_override: str, optional endpoint_override: str, optional
A custom endpoint URL to use instead of the standard Wii or Wii U endpoints. Defaults to no override, and if A custom endpoint URL to use instead of the standard Wii or Wii U endpoints. Defaults to no override, and if
set entirely overrides the "wiiu_endpoint" parameter. set entirely overrides the "wiiu_endpoint" parameter.
progress: DownloadCallback, optional
A callback function used to return the progress of the downloads. The provided callable must match the signature
defined in DownloadCallback.
Returns Returns
------- -------
List[bytes] List[bytes]
A list of all the downloaded contents. A list of all the downloaded contents.
See Also
--------
libWiiPy.title.nus.DownloadCallback
""" """
# Retrieve the content records from the TMD. # Retrieve the content records from the TMD.
content_records = tmd.content_records content_records = tmd.content_records
@@ -295,7 +375,7 @@ def download_contents(title_id: str, tmd: TMD, wiiu_endpoint: bool = False,
content_list = [] content_list = []
for content_id in content_ids: for content_id in content_ids:
# Call self.download_content() for each Content ID. # Call self.download_content() for each Content ID.
content = download_content(title_id, content_id, wiiu_endpoint, endpoint_override) content = download_content(title_id, content_id, wiiu_endpoint, endpoint_override, progress)
content_list.append(content) content_list.append(content)
return content_list return content_list
@@ -315,9 +395,10 @@ def _validate_endpoint(endpoint: str) -> str:
The validated NUS endpoint with the proper path. The validated NUS endpoint with the proper path.
""" """
# Find the root of the URL and then assemble the correct URL based on that. # Find the root of the URL and then assemble the correct URL based on that.
new_url = _urlparse(endpoint) # TODO: Rewrite in a way that makes more sense and un-stub
if new_url.netloc == "": #new_url = _urlparse(endpoint)
endpoint_url = "http://" + new_url.path + "/ccs/download/" #if new_url.netloc == "":
else: # endpoint_url = "http://" + new_url.path + "/ccs/download/"
endpoint_url = "http://" + new_url.netloc + "/ccs/download/" #else:
return endpoint_url # endpoint_url = "http://" + new_url.netloc + "/ccs/download/"
return endpoint

View File

@@ -9,7 +9,7 @@ import hashlib
from dataclasses import dataclass as _dataclass from dataclasses import dataclass as _dataclass
from .crypto import decrypt_title_key from .crypto import decrypt_title_key
from typing import List from typing import List
from .util import title_ver_standard_to_dec from .versions import title_ver_standard_to_dec
@_dataclass @_dataclass
@@ -57,7 +57,7 @@ class Ticket:
common_key_index : int common_key_index : int
The index of the common key required to decrypt this ticket's Title Key. The index of the common key required to decrypt this ticket's Title Key.
""" """
def __init__(self): def __init__(self) -> None:
# If this is a dev ticket # If this is a dev ticket
self.is_dev: bool = False # Defaults to false, set to true during load if this ticket is using dev certs. self.is_dev: bool = False # Defaults to false, set to true during load if this ticket is using dev certs.
# Signature blob header # Signature blob header
@@ -75,7 +75,7 @@ class Ticket:
self.title_version: int = 0 # Version of the ticket's associated title. self.title_version: int = 0 # Version of the ticket's associated title.
self.permitted_titles: bytes = b'' # Permitted titles mask self.permitted_titles: bytes = b'' # Permitted titles mask
# "Permit mask. The current disc title is ANDed with the inverse of this mask to see if the result matches the # "Permit mask. The current disc title is ANDed with the inverse of this mask to see if the result matches the
# Permitted Titles Mask." # Permitted Titles Mask." -WiiBrew
self.permit_mask: bytes = b'' self.permit_mask: bytes = b''
self.title_export_allowed: int = 0 # Whether title export is allowed with a PRNG key or not. self.title_export_allowed: int = 0 # Whether title export is allowed with a PRNG key or not.
self.common_key_index: int = 0 # Which common key should be used. 0 = Common Key, 1 = Korean Key, 2 = vWii Key self.common_key_index: int = 0 # Which common key should be used. 0 = Common Key, 1 = Korean Key, 2 = vWii Key
@@ -128,7 +128,7 @@ class Ticket:
self.console_id = int.from_bytes(ticket_data.read(4)) self.console_id = int.from_bytes(ticket_data.read(4))
# Title ID. # Title ID.
ticket_data.seek(0x1DC) ticket_data.seek(0x1DC)
self.title_id = binascii.hexlify(ticket_data.read(8)) self.title_id = ticket_data.read(8)
# Unknown data 1. # Unknown data 1.
ticket_data.seek(0x1E4) ticket_data.seek(0x1E4)
self.unknown1 = ticket_data.read(2) self.unknown1 = ticket_data.read(2)
@@ -202,7 +202,7 @@ class Ticket:
# Console ID. # Console ID.
ticket_data += int.to_bytes(self.console_id, 4) ticket_data += int.to_bytes(self.console_id, 4)
# Title ID. # Title ID.
ticket_data += binascii.unhexlify(self.title_id) ticket_data += self.title_id
# Unknown data 1. # Unknown data 1.
ticket_data += self.unknown1 ticket_data += self.unknown1
# Title version. # Title version.
@@ -281,8 +281,7 @@ class Ticket:
""" """
if self.signature != b'\x00' * 256: if self.signature != b'\x00' * 256:
return False return False
test_hash = hashlib.sha1(self.dump()[320:]).hexdigest() if hashlib.sha1(self.dump()[320:]).hexdigest()[:2] != '00':
if test_hash[:2] != '00':
return False return False
return True return True
@@ -295,8 +294,7 @@ class Ticket:
str str
The Title ID of the title. The Title ID of the title.
""" """
title_id_str = str(self.title_id.decode()) return str(self.title_id.decode())
return title_id_str
def get_common_key_type(self) -> str: def get_common_key_type(self) -> str:
""" """
@@ -318,6 +316,8 @@ class Ticket:
return "Korean" return "Korean"
case 2: case 2:
return "vWii" return "vWii"
case _:
return "Unknown"
def get_title_key(self) -> bytes: def get_title_key(self) -> bytes:
""" """
@@ -343,7 +343,7 @@ class Ticket:
""" """
if len(title_id) != 16: if len(title_id) != 16:
raise ValueError("Invalid Title ID! Title IDs must be 8 bytes long.") raise ValueError("Invalid Title ID! Title IDs must be 8 bytes long.")
self.title_id = title_id.encode() self.title_id = binascii.unhexlify(title_id.encode())
def set_title_version(self, new_version: str | int) -> None: def set_title_version(self, new_version: str | int) -> None:
""" """
@@ -368,8 +368,8 @@ class Ticket:
version_converted = title_ver_standard_to_dec(new_version, str(self.title_id.decode())) version_converted = title_ver_standard_to_dec(new_version, str(self.title_id.decode()))
self.title_version = version_converted self.title_version = version_converted
elif type(new_version) is int: elif type(new_version) is int:
# Validate that the version isn't higher than v65280. If the check passes, set that as the title version. # Validate that the version isn't higher than 0xFFFF (v65535).
if new_version > 65535: if new_version > 0xFFFF:
raise ValueError("Title version is not valid! Integer version number cannot exceed v65535.") raise ValueError("Title version is not valid! Integer version number cannot exceed v65535.")
self.title_version = new_version self.title_version = new_version
else: else:

View File

@@ -4,14 +4,16 @@
# See https://wiibrew.org/wiki/Title for details about how titles are formatted # See https://wiibrew.org/wiki/Title for details about how titles are formatted
import math import math
from .cert import (CertificateChain as _CertificateChain, from .cert import (CertificateChain as _CertificateChain,
verify_ca_cert as _verify_ca_cert, verify_cert_sig as _verify_cert_sig, verify_ca_cert as _verify_ca_cert, verify_cert_sig as _verify_cert_sig,
verify_tmd_sig as _verify_tmd_sig, verify_ticket_sig as _verify_ticket_sig) verify_tmd_sig as _verify_tmd_sig, verify_ticket_sig as _verify_ticket_sig)
from .content import ContentRegion as _ContentRegion from .content import ContentRegion as _ContentRegion
from .crypto import encrypt_title_key
from .ticket import Ticket as _Ticket from .ticket import Ticket as _Ticket
from .tmd import TMD as _TMD from .tmd import TMD as _TMD
from .types import ContentType
from .wad import WAD as _WAD from .wad import WAD as _WAD
from .crypto import encrypt_title_key
class Title: class Title:
@@ -33,7 +35,7 @@ class Title:
content: ContentRegion content: ContentRegion
A ContentRegion object containing the title's contents. A ContentRegion object containing the title's contents.
""" """
def __init__(self): def __init__(self) -> None:
self.wad: _WAD = _WAD() self.wad: _WAD = _WAD()
self.cert_chain: _CertificateChain = _CertificateChain() self.cert_chain: _CertificateChain = _CertificateChain()
self.tmd: _TMD = _TMD() self.tmd: _TMD = _TMD()
@@ -178,7 +180,7 @@ class Title:
self.tmd.set_title_version(title_version) self.tmd.set_title_version(title_version)
self.ticket.set_title_version(title_version) self.ticket.set_title_version(title_version)
def get_content_by_index(self, index: id, skip_hash=False) -> bytes: def get_content_by_index(self, index: int, skip_hash=False) -> bytes:
""" """
Gets an individual content from the content region based on the provided index, in decrypted form. Gets an individual content from the content region based on the provided index, in decrypted form.
@@ -194,6 +196,8 @@ class Title:
bytes bytes
The decrypted content listed in the content record. The decrypted content listed in the content record.
""" """
if self.ticket.title_id == "":
raise ValueError("A Ticket must be loaded to get decrypted content.")
dec_content = self.content.get_content_by_index(index, self.ticket.get_title_key(), skip_hash) dec_content = self.content.get_content_by_index(index, self.ticket.get_title_key(), skip_hash)
return dec_content return dec_content
@@ -213,6 +217,8 @@ class Title:
bytes bytes
The decrypted content listed in the content record. The decrypted content listed in the content record.
""" """
if self.ticket.title_id == "":
raise ValueError("A Ticket must be loaded to get decrypted content.")
dec_content = self.content.get_content_by_cid(cid, self.ticket.get_title_key(), skip_hash) dec_content = self.content.get_content_by_cid(cid, self.ticket.get_title_key(), skip_hash)
return dec_content return dec_content
@@ -239,7 +245,7 @@ class Title:
# For contents, get their sizes from the content records, because they store the intended sizes of the decrypted # For contents, get their sizes from the content records, because they store the intended sizes of the decrypted
# contents, which are usually different from the encrypted sizes. # contents, which are usually different from the encrypted sizes.
for record in self.content.content_records: for record in self.content.content_records:
if record.content_type == 32769: if record.content_type == ContentType.SHARED:
if absolute: if absolute:
title_size += record.content_size title_size += record.content_size
else: else:
@@ -317,8 +323,8 @@ class Title:
# Update the TMD to match. # Update the TMD to match.
self.tmd.content_records = self.content.content_records self.tmd.content_records = self.content.content_records
def set_enc_content(self, enc_content: bytes, index: int, content_size: int, content_hash: bytes, cid: int = None, def set_enc_content(self, enc_content: bytes, index: int, content_size: int, content_hash: bytes,
content_type: int = None) -> None: cid: int | None = None, content_type: int | None = None) -> None:
""" """
Sets the content at the provided index to the provided new encrypted content. The provided hash and content size Sets the content at the provided index to the provided new encrypted content. The provided hash and content size
are set in the corresponding content record. A new Content ID or content type can also be specified, but if it are set in the corresponding content record. A new Content ID or content type can also be specified, but if it
@@ -346,7 +352,8 @@ class Title:
# Update the TMD to match. # Update the TMD to match.
self.tmd.content_records = self.content.content_records self.tmd.content_records = self.content.content_records
def set_content(self, dec_content: bytes, index: int, cid: int = None, content_type: int = None) -> None: def set_content(self, dec_content: bytes, index: int, cid: int | None = None,
content_type: int | None = None) -> None:
""" """
Sets the content at the provided index to the provided new decrypted content. The hash and content size of this Sets the content at the provided index to the provided new decrypted content. The hash and content size of this
content will be generated and then set in the corresponding content record. A new Content ID or content type can content will be generated and then set in the corresponding content record. A new Content ID or content type can
@@ -438,15 +445,15 @@ class Title:
-------- --------
libWiiPy.title.cert libWiiPy.title.cert
""" """
# The entire chain needs to be verified, so start with the CA cert and work our way down. If anything fails # I did not understand short-circuiting when I originally wrote this code, and it was 5 nested if statements
# along the way, future steps don't matter so exit the descending if's and return False. # which looked silly. I now understand that this is functionally identical!
try: try:
if _verify_ca_cert(self.cert_chain.ca_cert) is True: if _verify_ca_cert(self.cert_chain.ca_cert) and \
if _verify_cert_sig(self.cert_chain.ca_cert, self.cert_chain.tmd_cert) is True: _verify_cert_sig(self.cert_chain.ca_cert, self.cert_chain.tmd_cert) and \
if _verify_tmd_sig(self.cert_chain.tmd_cert, self.tmd) is True: _verify_tmd_sig(self.cert_chain.tmd_cert, self.tmd) and \
if _verify_cert_sig(self.cert_chain.ca_cert, self.cert_chain.ticket_cert) is True: _verify_cert_sig(self.cert_chain.ca_cert, self.cert_chain.ticket_cert) and \
if _verify_ticket_sig(self.cert_chain.ticket_cert, self.ticket) is True: _verify_ticket_sig(self.cert_chain.ticket_cert, self.ticket):
return True return True
except ValueError: except ValueError:
raise ValueError("This title's certificate chain is not valid, or does not match the signature type of " raise ValueError("This title's certificate chain is not valid, or does not match the signature type of "
"the TMD/Ticket.") "the TMD/Ticket.")

View File

@@ -10,9 +10,9 @@ import math
import struct import struct
from typing import List from typing import List
from enum import IntEnum as _IntEnum from enum import IntEnum as _IntEnum
from ..types import _ContentRecord
from ..shared import _bitmask from .types import ContentRecord, ContentType, TitleType, Region
from .util import title_ver_dec_to_standard, title_ver_standard_to_dec from .versions import title_ver_standard_to_dec
class TMD: class TMD:
@@ -34,9 +34,9 @@ class TMD:
num_contents : int num_contents : int
The number of contents listed in the TMD. The number of contents listed in the TMD.
""" """
def __init__(self): def __init__(self) -> None:
self.blob_header: bytes = b'' self.blob_header: bytes = b''
self.signature_type: int = 0 self.signature_type: bytes = b''
self.signature: bytes = b'' self.signature: bytes = b''
self.signature_issuer: str = "" # Follows the format "Root-CA%08x-CP%08x" self.signature_issuer: str = "" # Follows the format "Root-CA%08x-CP%08x"
self.tmd_version: int = 0 # This seems to always be 0 no matter what? self.tmd_version: int = 0 # This seems to always be 0 no matter what?
@@ -55,11 +55,10 @@ class TMD:
self.reserved2: bytes = b'' # Other "Reserved" data from WiiBrew. self.reserved2: bytes = b'' # Other "Reserved" data from WiiBrew.
self.access_rights: int = 0 self.access_rights: int = 0
self.title_version: int = 0 # The version of the associated title. self.title_version: int = 0 # The version of the associated title.
self.title_version_converted: int = 0 # The title version in vX.X format.
self.num_contents: int = 0 # The number of contents contained in the associated title. self.num_contents: int = 0 # The number of contents contained in the associated title.
self.boot_index: int = 0 # The content index that contains the bootable executable. self.boot_index: int = 0 # The content index that contains the bootable executable.
self.minor_version: int = 0 # Minor version (unused typically). self.minor_version: int = 0 # Minor version (unused typically).
self.content_records: List[_ContentRecord] = [] self.content_records: List[ContentRecord] = []
def load(self, tmd: bytes) -> None: def load(self, tmd: bytes) -> None:
""" """
@@ -137,8 +136,6 @@ class TMD:
# Version number straight from the TMD. # Version number straight from the TMD.
tmd_data.seek(0x1DC) tmd_data.seek(0x1DC)
self.title_version = int.from_bytes(tmd_data.read(2)) self.title_version = int.from_bytes(tmd_data.read(2))
# Calculate the converted version number via util module.
self.title_version_converted = title_ver_dec_to_standard(self.title_version, self.title_id, bool(self.vwii))
# The number of contents listed in the TMD. # The number of contents listed in the TMD.
tmd_data.seek(0x1DE) tmd_data.seek(0x1DE)
self.num_contents = int.from_bytes(tmd_data.read(2)) self.num_contents = int.from_bytes(tmd_data.read(2))
@@ -154,7 +151,7 @@ class TMD:
tmd_data.seek(0x1E4 + (36 * content)) tmd_data.seek(0x1E4 + (36 * content))
content_record_hdr = struct.unpack(">LHH4x4s20s", tmd_data.read(36)) content_record_hdr = struct.unpack(">LHH4x4s20s", tmd_data.read(36))
self.content_records.append( self.content_records.append(
_ContentRecord(int(content_record_hdr[0]), int(content_record_hdr[1]), ContentRecord(int(content_record_hdr[0]), int(content_record_hdr[1]),
int(content_record_hdr[2]), int.from_bytes(content_record_hdr[3]), int(content_record_hdr[2]), int.from_bytes(content_record_hdr[3]),
binascii.hexlify(content_record_hdr[4]))) binascii.hexlify(content_record_hdr[4])))
@@ -254,7 +251,8 @@ class TMD:
self.minor_version = current_int self.minor_version = current_int
# Trim off the first 320 bytes, because we're only looking for the hash of the TMD's body. # Trim off the first 320 bytes, because we're only looking for the hash of the TMD's body.
# This is a try-except because an OverflowError will be thrown if the number being used to brute-force the # This is a try-except because an OverflowError will be thrown if the number being used to brute-force the
# hash gets too big, as it is only a 16-bit integer. If that happens, then fakesigning has failed. # hash gets too big, as it is only a 16-bit integer. If that happens, then fakesigning has failed. This
# shouldn't ever realistically happen, though.
try: try:
test_hash = hashlib.sha1(self.dump()[320:]).hexdigest() test_hash = hashlib.sha1(self.dump()[320:]).hexdigest()
except OverflowError: except OverflowError:
@@ -276,8 +274,7 @@ class TMD:
""" """
if self.signature != b'\x00' * 256: if self.signature != b'\x00' * 256:
return False return False
test_hash = hashlib.sha1(self.dump()[320:]).hexdigest() if hashlib.sha1(self.dump()[320:]).hexdigest()[:2] != '00':
if test_hash[:2] != '00':
return False return False
return True return True
@@ -295,16 +292,18 @@ class TMD:
The region of the title. The region of the title.
""" """
match self.region: match self.region:
case 0: case Region.JPN:
return "JPN" return "JPN"
case 1: case Region.USA:
return "USA" return "USA"
case 2: case Region.EUR:
return "EUR" return "EUR"
case 3: case Region.WORLD:
return "None" return "World"
case 4: case Region.KOR:
return "KOR" return "KOR"
case _:
raise ValueError(f"Title contains unknown region \"{self.region}\".")
def get_title_type(self) -> str: def get_title_type(self) -> str:
""" """
@@ -319,19 +318,19 @@ class TMD:
The type of the title. The type of the title.
""" """
match self.title_id[:8]: match self.title_id[:8]:
case '00000001': case TitleType.SYSTEM:
return "System" return "System"
case '00010000': case TitleType.GAME:
return "Game" return "Game"
case '00010001': case TitleType.CHANNEL:
return "Channel" return "Channel"
case '00010002': case TitleType.SYSTEM_CHANNEL:
return "SystemChannel" return "SystemChannel"
case '00010004': case TitleType.GAME_CHANNEL:
return "GameChannel" return "GameChannel"
case '00010005': case TitleType.DLC:
return "DLC" return "DLC"
case '00010008': case TitleType.HIDDEN_CHANNEL:
return "HiddenChannel" return "HiddenChannel"
case _: case _:
return "Unknown" return "Unknown"
@@ -361,20 +360,20 @@ class TMD:
# This is the literal index in the list of content that we're going to get. # This is the literal index in the list of content that we're going to get.
target_index = current_indices.index(content_index) target_index = current_indices.index(content_index)
match self.content_records[target_index].content_type: match self.content_records[target_index].content_type:
case 1: case ContentType.NORMAL:
return "Normal" return "Normal"
case 2: case ContentType.DEVELOPMENT:
return "Development/Unknown" return "Development/Unknown"
case 3: case ContentType.HASH_TREE:
return "Hash Tree" return "Hash Tree"
case 16385: case ContentType.DLC:
return "DLC" return "DLC"
case 32769: case ContentType.SHARED:
return "Shared" return "Shared"
case _: case _:
return "Unknown" return "Unknown"
def get_content_record(self, record) -> _ContentRecord: def get_content_record(self, record) -> ContentRecord:
""" """
Gets the content record at the specified index. Gets the content record at the specified index.
@@ -391,8 +390,8 @@ class TMD:
if record < self.num_contents: if record < self.num_contents:
return self.content_records[record] return self.content_records[record]
else: else:
raise IndexError("Invalid content record! TMD lists '" + str(self.num_contents - 1) + raise IndexError(f"Invalid content record! TMD lists \"{self.num_contents - 1}\" contents "
"' contents but index was '" + str(record) + "'!") f"but index was \"{record}\"!")
def get_content_size(self, absolute=False, dlc=False) -> int: def get_content_size(self, absolute=False, dlc=False) -> int:
""" """
@@ -416,13 +415,13 @@ class TMD:
""" """
title_size = 0 title_size = 0
for record in self.content_records: for record in self.content_records:
if record.content_type == 0x8001: if record.content_type == ContentType.SHARED:
if absolute: if absolute:
title_size += record.content_size title_size += record.content_size
elif record.content_type == 0x4001: elif record.content_type == ContentType.DLC:
if dlc: if dlc:
title_size += record.content_size title_size += record.content_size
elif record.content_type != 3: elif record.content_type != ContentType.DEVELOPMENT:
title_size += record.content_size title_size += record.content_size
return title_size return title_size
@@ -451,10 +450,6 @@ class TMD:
blocks = math.ceil(title_size_bytes / 131072) blocks = math.ceil(title_size_bytes / 131072)
return blocks return blocks
class AccessFlags(_IntEnum):
AHB = 0
DVD_VIDEO = 1
def get_access_right(self, flag: int) -> bool: def get_access_right(self, flag: int) -> bool:
""" """
Gets whether the specified access rights flag is enabled or not. This is done by checking the specified bit. Gets whether the specified access rights flag is enabled or not. This is done by checking the specified bit.
@@ -474,7 +469,7 @@ class TMD:
-------- --------
libWiiPy.title.tmd.TMD.AccessFlags libWiiPy.title.tmd.TMD.AccessFlags
""" """
return bool(self.access_rights & _bitmask(flag)) return bool(self.access_rights & (1 << flag))
def set_title_id(self, title_id) -> None: def set_title_id(self, title_id) -> None:
""" """
@@ -500,7 +495,7 @@ class TMD:
Parameters Parameters
---------- ----------
new_version : str, int new_version : str, int
The new version of the title. See description for valid formats. The new version of the title.
""" """
if type(new_version) is str: if type(new_version) is str:
# Validate string input is in the correct format, then validate that the version isn't higher than v255.0. # Validate string input is in the correct format, then validate that the version isn't higher than v255.0.
@@ -510,16 +505,20 @@ class TMD:
raise ValueError("Title version is not valid! String version must be entered in format \"X.X\".") raise ValueError("Title version is not valid! String version must be entered in format \"X.X\".")
if int(version_str_split[0]) > 255 or int(version_str_split[1]) > 255: if int(version_str_split[0]) > 255 or int(version_str_split[1]) > 255:
raise ValueError("Title version is not valid! String version number cannot exceed v255.255.") raise ValueError("Title version is not valid! String version number cannot exceed v255.255.")
self.title_version_converted = new_version version_converted: int = title_ver_standard_to_dec(new_version, self.title_id)
version_converted = title_ver_standard_to_dec(new_version, self.title_id)
self.title_version = version_converted self.title_version = version_converted
elif type(new_version) is int: elif type(new_version) is int:
# Validate that the version isn't higher than v65280. If the check passes, set that as the title version, # Validate that the version isn't higher than 0xFFFF (v65535).
# then convert to standard form and set that as well. if new_version > 0xFFFF:
if new_version > 65535:
raise ValueError("Title version is not valid! Integer version number cannot exceed v65535.") raise ValueError("Title version is not valid! Integer version number cannot exceed v65535.")
self.title_version = new_version self.title_version = new_version
version_converted = title_ver_dec_to_standard(new_version, self.title_id, bool(self.vwii))
self.title_version_converted = version_converted
else: else:
raise TypeError("Title version type is not valid! Type must be either integer or string.") raise TypeError("Title version type is not valid! Type must be either integer or string.")
class AccessFlags(_IntEnum):
"""
Flags set in a TMD's access rights field used to enable specific feature access.
"""
AHB = 0
DVD_VIDEO = 1

View File

@@ -1,11 +1,14 @@
# "types.py" from libWiiPy by NinjaCheetah & Contributors # "title/types.py" from libWiiPy by NinjaCheetah & Contributors
# https://github.com/NinjaCheetah/libWiiPy # https://github.com/NinjaCheetah/libWiiPy
#
# Shared types used across the title module.
from dataclasses import dataclass from dataclasses import dataclass as _dataclass
from enum import IntEnum as _IntEnum, StrEnum as _StrEnum
@dataclass @_dataclass
class _ContentRecord: class ContentRecord:
""" """
A content record object that contains the details of a content contained in a title. This information must match A content record object that contains the details of a content contained in a title. This information must match
the content stored at the index in the record, or else the content will not decrypt properly, as the hash of the the content stored at the index in the record, or else the content will not decrypt properly, as the hash of the
@@ -29,3 +32,38 @@ class _ContentRecord:
content_type: int # Type of content, possible values of: 0x0001: Normal, 0x4001: DLC, 0x8001: Shared. content_type: int # Type of content, possible values of: 0x0001: Normal, 0x4001: DLC, 0x8001: Shared.
content_size: int content_size: int
content_hash: bytes content_hash: bytes
class ContentType(_IntEnum):
"""
The type of an individual piece of content.
"""
NORMAL = 0x0001
DEVELOPMENT = 0x0002
HASH_TREE = 0x0003
DLC = 0x4001
SHARED = 0x8001
class TitleType(_StrEnum):
"""
The type of a title.
"""
SYSTEM = "00000001"
GAME = "00010000"
CHANNEL = "00010001"
SYSTEM_CHANNEL = "00010002"
GAME_CHANNEL = "00010004"
DLC = "00010005"
HIDDEN_CHANNEL = "00010008"
class Region(_IntEnum):
"""
The region of a title.
"""
JPN = 0
USA = 1
EUR = 2
WORLD = 3
KOR = 4

View File

@@ -1,10 +1,9 @@
# "title/util.py" from libWiiPy by NinjaCheetah & Contributors # "title/versions.py" from libWiiPy by NinjaCheetah & Contributors
# https://github.com/NinjaCheetah/libWiiPy # https://github.com/NinjaCheetah/libWiiPy
# #
# General title-related utilities that don't fit within a specific module. # Functions for converting the format that a title's version is in.
import math from ..constants import _WII_MENU_VERSIONS, _VWII_MENU_VERSIONS
from ..shared import _wii_menu_versions, _vwii_menu_versions
def title_ver_dec_to_standard(version: int, title_id: str, vwii: bool = False) -> str: def title_ver_dec_to_standard(version: int, title_id: str, vwii: bool = False) -> str:
@@ -27,26 +26,18 @@ def title_ver_dec_to_standard(version: int, title_id: str, vwii: bool = False) -
str str
The version of the title, in standard form. The version of the title, in standard form.
""" """
version_out = ""
if title_id == "0000000100000002": if title_id == "0000000100000002":
if vwii: try:
try: if vwii:
version_out = list(_vwii_menu_versions.keys())[list(_vwii_menu_versions.values()).index(version)] return list(_VWII_MENU_VERSIONS.keys())[list(_VWII_MENU_VERSIONS.values()).index(version)]
except ValueError: else:
version_out = "" return list(_WII_MENU_VERSIONS.keys())[list(_WII_MENU_VERSIONS.values()).index(version)]
else: except ValueError:
try: raise ValueError(f"Unrecognized System Menu version \"{version}\".")
version_out = list(_wii_menu_versions.keys())[list(_wii_menu_versions.values()).index(version)]
except ValueError:
version_out = ""
else: else:
# For most channels, we need to get the floored value of version / 256 for the major version, and the version % # Typical titles use a two-byte version format where the upper byte is the major version, and the lower byte is
# 256 as the minor version. Minor versions > 9 are intended, as Nintendo themselves frequently used them. # the minor version.
version_upper = math.floor(version / 256) return f"{version >> 8}.{version & 0xFF}"
version_lower = version % 256
version_out = f"{version_upper}.{version_lower}"
return version_out
def title_ver_standard_to_dec(version: str, title_id: str) -> int: def title_ver_standard_to_dec(version: str, title_id: str) -> int:
@@ -68,13 +59,15 @@ def title_ver_standard_to_dec(version: str, title_id: str) -> int:
int int
The version of the title, in decimal form. The version of the title, in decimal form.
""" """
version_out = 0
if title_id == "0000000100000002": if title_id == "0000000100000002":
raise ValueError("The System Menu's version cannot currently be converted.") for key in _WII_MENU_VERSIONS.keys():
if version.casefold() == key.casefold():
return _WII_MENU_VERSIONS[key]
for key in _VWII_MENU_VERSIONS.keys():
if version.casefold() == key.casefold():
return _VWII_MENU_VERSIONS[key]
raise ValueError(f"Unrecognized System Menu version \"{version}\".")
else: else:
version_str_split = version.split(".") version_str_split = version.split(".")
version_upper = int(version_str_split[0]) * 256 version_out = (int(version_str_split[0]) << 8) + int(version_str_split[1])
version_lower = int(version_str_split[1]) return version_out
version_out = version_upper + version_lower
return version_out

View File

@@ -29,7 +29,7 @@ class WAD:
wad_meta_size : int wad_meta_size : int
The size of the WAD's meta/footer. The size of the WAD's meta/footer.
""" """
def __init__(self): def __init__(self) -> None:
self.wad_hdr_size: int = 64 self.wad_hdr_size: int = 64
self.wad_type: str = "Is" self.wad_type: str = "Is"
self.wad_version: bytes = b'\x00\x00' self.wad_version: bytes = b'\x00\x00'
@@ -49,17 +49,17 @@ class WAD:
self.wad_content_data: bytes = b'' self.wad_content_data: bytes = b''
self.wad_meta_data: bytes = b'' self.wad_meta_data: bytes = b''
def load(self, wad_data: bytes) -> None: def load(self, wad: bytes) -> None:
""" """
Loads raw WAD data and sets all attributes of the WAD object. This allows for manipulating an already Loads raw WAD data and sets all attributes of the WAD object. This allows for manipulating an already
existing WAD file. existing WAD file.
Parameters Parameters
---------- ----------
wad_data : bytes wad : bytes
The data for the WAD file to load. The data for the WAD file to load.
""" """
with io.BytesIO(wad_data) as wad_data: with io.BytesIO(wad) as wad_data:
# Read the first 8 bytes of the file to ensure that it's a WAD. Has two possible valid values for the two # Read the first 8 bytes of the file to ensure that it's a WAD. Has two possible valid values for the two
# different types of WADs that might be encountered. # different types of WADs that might be encountered.
wad_data.seek(0x0) wad_data.seek(0x0)
@@ -311,7 +311,7 @@ class WAD:
# Calculate the size of the new Ticket data. # Calculate the size of the new Ticket data.
self.wad_tik_size = len(tik_data) self.wad_tik_size = len(tik_data)
def set_content_data(self, content_data, size: int = None) -> None: def set_content_data(self, content_data, size: int | None = None) -> None:
""" """
Sets the content data of the WAD. Also calculates the new size. Sets the content data of the WAD. Also calculates the new size.