mirror of
https://github.com/NinjaCheetah/libWiiPy.git
synced 2026-03-05 08:35:28 -05:00
Compare commits
7 Commits
8adbef26b1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
374358711b
|
|||
|
ce5d118de1
|
|||
|
6d38df9133
|
|||
|
2ca2ff1f44
|
|||
|
79ab33c18a
|
|||
|
e06bb39f4c
|
|||
|
8269a0db98
|
@@ -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"]
|
||||||
|
|||||||
@@ -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__
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
14
docs/source/title/types.md
Normal file
14
docs/source/title/types.md
Normal 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:
|
||||||
|
```
|
||||||
@@ -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:
|
|
||||||
```
|
|
||||||
14
docs/source/title/versions.md
Normal file
14
docs/source/title/versions.md
Normal 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:
|
||||||
|
```
|
||||||
@@ -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"]
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
build
|
build
|
||||||
pycryptodome
|
pycryptodome
|
||||||
requests
|
requests
|
||||||
|
types-requests
|
||||||
sphinx
|
sphinx
|
||||||
sphinx-book-theme
|
sphinx-book-theme
|
||||||
myst-parser
|
myst-parser
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
65
src/libWiiPy/constants.py
Normal 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,
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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
0
src/libWiiPy/py.typed
Normal 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,
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 *
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
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
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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:
|
||||||
"""
|
"""
|
||||||
@@ -370,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:
|
||||||
|
|||||||
@@ -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.")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user