35 Commits

Author SHA1 Message Date
1ae649afac Update README in preparation for v0.5.0 release 2024-08-12 15:26:03 -04:00
b782e5dea5 Fix typing issue with emunand class 2024-08-08 13:39:22 -04:00
894aa3a04b Added new module for EmuNAND features previously found in WiiPy 2024-08-08 13:24:10 -04:00
152a13fbe2 Rewrote most of U8 module, now extracts and packs all archives as expected 2024-08-07 22:46:28 -04:00
72a8b9b6a6 Added automatic documentation for modules new since v0.4.1 2024-08-04 01:04:47 -04:00
128f4a9303 Merge remote-tracking branch 'origin/main' 2024-08-03 23:26:50 -04:00
bab777b8b9 Added new module for handling sys files, currently supports uid.sys 2024-08-03 23:26:32 -04:00
fb87c2c58c Added methods to check if a TMD, Ticket, and Title are fakesigned 2024-08-03 14:01:09 -04:00
6220821a2f Cleaned up AccessFlags enum in TMD class 2024-08-03 13:44:15 -04:00
580ba8526f Add method to check for specific access rights in TMD 2024-08-03 13:36:35 -04:00
7e308a35eb Save access rights as an int and not bytes 2024-08-03 13:13:29 -04:00
194b65c6d6 Added hard-coded table of System Menu versions for conversions 2024-08-03 02:12:47 -04:00
cfd5abac7e Merge remote-tracking branch 'origin/main' 2024-08-02 23:57:58 -04:00
7edf764768 Fix handling of title and content types in tmd module 2024-08-02 23:57:43 -04:00
544e65a109 Fix title version cap 2024-08-02 14:28:22 -04:00
bcbdd284e9 Don't automatically set content to normal when applying IOS patches 2024-08-02 08:40:39 -04:00
415af7b8b8 Write the footer at the bottom when dumping 2024-08-01 17:04:59 -04:00
f81398e854 Disable automatically making patched content non-shared 2024-08-01 15:49:06 -04:00
60975dc62d Add experimental patch to make IOS work without a DVD drive 2024-07-31 01:29:54 -04:00
40e4459893 Added some basic tests for the commonkeys and nus modules 2024-07-29 19:28:28 -04:00
5c56eabe9f Fix a minor bug in how title versions were handled in the ticket module 2024-07-29 16:46:38 -04:00
9d26ff74ff Return count of applied patches from iospatcher instead of erroring on 0 applied 2024-07-29 15:29:51 -04:00
18b54af091 Fix formatting issue with setting the Title ID in the ticket module 2024-07-28 03:33:28 -04:00
2d67f982dc Re-encrypt Title Key when setting the Title ID (title module only) 2024-07-28 03:28:44 -04:00
d6e6352d0a Correctly generate IV for Title Key decrpytion for all Title ID formats 2024-07-28 03:15:51 -04:00
7daba7ec86 Add IOSPatcher to apply patches to IOS WADs loaded into a Title()
Also fixes a MAJOR bug with WAD packing where changes to the content records would be dropped when dumping a WAD (!!)
2024-07-28 01:43:46 -04:00
930e09828e Always return lowercase file names from SharedContentMap.add_content() 2024-07-27 20:47:22 -04:00
a5ce7e9cd1 Minor fix for accepting different input types for SharedContentMap.add_content() 2024-07-27 19:56:43 -04:00
76b773ee36 Fix adding content to the content map if it is empty 2024-07-27 19:36:27 -04:00
817a2c9ac5 Added SharedContentMap to content module to handle content.map files 2024-07-27 19:22:21 -04:00
102da808e6 Add option to skip hash checks when unpacking a WAD 2024-07-25 21:14:12 -04:00
f7f67d3414 Rewrote most of the content module, code is much cleaner now
It also has more checks, so that should ensure that more errors get caught and aren't either ignored or allowed to fall through to the interpreter.
Also, part of this cleanup is that the content module now entirely operates on content indexes and not literal indexes, so this makes sure that WADs where the content and literal indexes don't match are handed properly
2024-07-25 17:15:26 -04:00
39eecec864 Fall back on key 0 when invalid, fix footer reading code 2024-07-25 13:09:01 -04:00
5f4fa8827c Added new methods to TMD/Ticket/Title modules for changing title versions 2024-07-22 02:42:04 -04:00
e70b9570de Fix handling WADs where content index != actual index in the array 2024-07-20 15:07:23 -04:00
21 changed files with 1503 additions and 296 deletions

View File

@@ -1,22 +1,28 @@
![banner](https://github.com/NinjaCheetah/libWiiPy/assets/58050615/00ea4c41-673c-4a74-addb-fbb40b4313c8)
# libWiiPy
libWiiPy is a modern Python 3 library for handling the various files and formats found on the Wii. It aims to be simple to use, well maintained, and offer as many features as reasonably possible in one library, so that a newly-written Python program could reasonably do 100% of its Wii-related work with just one library. It also aims to be fully cross-platform, so that any tools written with it can also be cross-platform.
libWiiPy is a modern Python 3 library for handling the various files and formats found on the Wii. It aims to be simple to use, well maintained, and offer as many features as reasonably possible in one library, so that a newly-written Python program could do 100% of its Wii-related work with just one library. It also aims to be fully cross-platform, so that any tools written with it can also be cross-platform.
libWiiPy is inspired by [libWiiSharp](https://github.com/TheShadowEevee/libWiiSharp), which was originally created by `Leathl` and is now maintained by [@TheShadowEevee](https://github.com/TheShadowEevee). If you're looking for a Wii library that isn't in Python, then go check it out!
# Features
This list will expand as libWiiPy is developed, but these features are currently available:
- TMD and Ticket parsing (`.tmd`, `.tik`)
- Title content decryption, re-encryption
- Packing and unpacking WAD files (`.wad`)
- TMD and Ticket parsing/editing (`.tmd`, `.tik`)
- Title parsing/editing, including content encryption/decryption
- WAD file parsing/editing (`.wad`)
- Downloading titles from the NUS
- Packing and unpacking U8 archives (`.app`, `.arc`)
- Decompressing ASH files (`.ash`, both the standard variants and the variants found in My Pokémon Ranch)
- IOS patching
- NAND-related functionality:
- EmuNAND title management (currently requires an existing EmuNAND)
- `content.map` parsing/editing
- `uid.sys` parsing/editing
- Assorted miscellaneous features used to make the other core features possible
For a more detailed look at what's available in libWiiPy, check out our [API docs](https://ninjacheetah.github.io/libWiiPy).
# Usage
A wiki, and in the future a potential documenation site, is being worked on, and can be accessed [here](https://github.com/NinjaCheetah/libWiiPy/wiki). It is currently fairly barebones, but it will be improved in the future.
The easiest way to get libWiiPy for your project is to install the latest version of the library from PyPI, as shown below.
```sh
pip install -U libWiiPy
@@ -29,6 +35,8 @@ pip install -U git+https://github.com/NinjaCheetah/libWiiPy
```
Please be aware that because libWiiPy is in a very early state right now, many features may be subject to change, and methods and properties available now have the potential to disappear in the future.
For more tips on getting started, see our guide [here](https://ninjacheetah.github.io/libWiiPy/usage/installation.html).
# Building
To build this package locally, the steps are quite simple, and should apply to all platforms. Make sure you've set up your `venv` first!

View File

@@ -3,7 +3,6 @@
## Submodules
### libWiiPy.title.commonkeys module
```{eval-rst}
.. automodule:: libWiiPy.title.commonkeys
:members:
@@ -12,7 +11,6 @@
```
### libWiiPy.title.content module
```{eval-rst}
.. automodule:: libWiiPy.title.content
:members:
@@ -21,55 +19,86 @@
```
### libWiiPy.title.crypto module
```{eval-rst}
.. automodule:: libWiiPy.title.crypto
:members:
:undoc-members:
:show-inheritance:
```
### libWiiPy.title.nus module
### libWiiPy.title.emunand module
```{eval-rst}
.. automodule:: libWiiPy.title.emunand
:members:
:undoc-members:
:show-inheritance:
```
### libWiipy.title.iospatcher module
```{eval-rst}
.. automodule:: libWiiPy.title.iospatcher
:members:
:undoc-members:
:show-inheritance:
```
### libWiiPy.title.nus module
```{eval-rst}
.. automodule:: libWiiPy.title.nus
:members:
:undoc-members:
:show-inheritance:
```
### libWiiPy.title.ticket module
### libWiiPy.title.sys module
```{eval-rst}
.. automodule:: libWiiPy.title.sys
:members:
:undoc-members:
:show-inheritance:
```
### libWiiPy.title.ticket module
```{eval-rst}
.. automodule:: libWiiPy.title.ticket
:members:
:undoc-members:
:show-inheritance:
```
### libWiiPy.title.title module
### libWiiPy.title.title module
```{eval-rst}
.. automodule:: libWiiPy.title.title
:members:
:undoc-members:
:show-inheritance:
```
### libWiiPy.title.tmd module
### libWiiPy.title.tmd module
```{eval-rst}
.. automodule:: libWiiPy.title.tmd
:members:
:undoc-members:
:show-inheritance:
```
### libWiiPy.title.wad module
### libWiiPy.title.util module
```{eval-rst}
.. automodule:: libWiiPy.title.util
:members:
:undoc-members:
:show-inheritance:
```
### libWiiPy.title.wad module
```{eval-rst}
.. automodule:: libWiiPy.title.wad
:members:
:undoc-members:
:show-inheritance:
```
## Module contents
## Module contents
```{eval-rst}
.. automodule:: libWiiPy.title
:members:

View File

@@ -1,6 +1,6 @@
[project]
name = "libWiiPy"
version = "0.4.1"
version = "0.5.0"
authors = [
{ name="NinjaCheetah", email="ninjacheetah@ncxprogramming.com" },
{ name="Lillian Skinner", email="lillian@randommeaninglesscharacters.com" }

View File

@@ -8,7 +8,7 @@ import os
import pathlib
from dataclasses import dataclass as _dataclass
from typing import List
from ..shared import _align_value
from ..shared import _align_value, _pad_bytes
@_dataclass
@@ -47,7 +47,10 @@ class U8Archive:
self.u8_node_list: List[_U8Node] = [] # All the nodes in the header of a U8 file.
self.file_name_list: List[str] = []
self.file_data_list: List[bytes] = []
self.u8_file_structure = dict
self.root_node_offset: int = 0
self.header_size: int = 0
self.data_offset: int = 0
self.root_node: _U8Node = _U8Node(0, 0, 0, 0)
def load(self, u8_data: bytes) -> None:
"""
@@ -64,26 +67,25 @@ class U8Archive:
self.u8_magic = u8_data.read(4)
if self.u8_magic != b'\x55\xAA\x38\x2D':
raise TypeError("This is not a valid U8 archive!")
# The following code is all skipped because these values really don't matter for extraction. They honestly
# really only matter to my code when they get calculated and used for packing.
# Offset of the root node, which will always be 0x20.
# root_node_offset = int(binascii.hexlify(u8_data.read(4)), 16)
self.root_node_offset = int.from_bytes(u8_data.read(4))
# The size of the U8 header.
# header_size = int(binascii.hexlify(u8_data.read(4)), 16)
self.header_size = int.from_bytes(u8_data.read(4))
# The offset of the data, which is root_node_offset + header_size, aligned to 0x10.
# data_offset = int(binascii.hexlify(u8_data.read(4)), 16)
# Seek ahead to the size defined in the root node, because it's the total number of nodes in the file. The
# rest of the data in the root node (not that it really matters) will get read when we read the whole list.
u8_data.seek(u8_data.tell() + 36)
self.data_offset = int.from_bytes(u8_data.read(4))
# Seek past 16 bytes of padding, then load the root node.
u8_data.seek(u8_data.tell() + 16)
root_node_type = int.from_bytes(u8_data.read(1))
root_node_name_offset = int.from_bytes(u8_data.read(3))
root_node_data_offset = int.from_bytes(u8_data.read(4))
root_node_size = int.from_bytes(u8_data.read(4))
self.root_node = _U8Node(root_node_type, root_node_name_offset, root_node_data_offset, root_node_size)
# Seek back before the root node so that it gets read with all the rest.
u8_data.seek(u8_data.tell() - 12)
# Iterate over the number of nodes that the root node lists.
for node in range(root_node_size):
node_type = int.from_bytes(u8_data.read(2))
node_name_offset = int.from_bytes(u8_data.read(2))
node_type = int.from_bytes(u8_data.read(1))
node_name_offset = int.from_bytes(u8_data.read(3))
node_data_offset = int.from_bytes(u8_data.read(4))
node_size = int.from_bytes(u8_data.read(4))
self.u8_node_list.append(_U8Node(node_type, node_name_offset, node_data_offset, node_size))
@@ -120,15 +122,19 @@ class U8Archive:
# Add the number of bytes used for each file/folder name in the string table.
for file_name in self.file_name_list:
header_size += len(file_name) + 1
# The initial data offset is equal to the file header (32 bytes) + node data aligned to 16 bytes.
data_offset = _align_value(header_size + 32, 16)
# The initial data offset is equal to the file header (32 bytes) + node data aligned to 64 bytes.
data_offset = _align_value(header_size + 32, 64)
# 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_name_offset = 0
for node in range(len(self.u8_node_list)):
if self.u8_node_list[node].type == 0:
self.u8_node_list[node].data_offset = current_data_offset
current_data_offset += self.u8_node_list[node].size
# Begin joining all the U8 archive data into one variable.
self.u8_node_list[node].data_offset = _align_value(current_data_offset, 32)
current_data_offset += _align_value(self.u8_node_list[node].size, 32)
# 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
current_name_offset += len(self.file_name_list[node]) + 1
# Begin joining all the U8 archive data into bytes.
u8_data = b''
# Magic number.
u8_data += b'\x55\xAA\x38\x2D'
@@ -142,19 +148,18 @@ class U8Archive:
u8_data += (b'\x00' * 16)
# Iterate over all the U8 nodes and dump them.
for node in self.u8_node_list:
u8_data += int.to_bytes(node.type, 2)
u8_data += int.to_bytes(node.name_offset, 2)
u8_data += int.to_bytes(node.type, 1)
u8_data += int.to_bytes(node.name_offset, 3)
u8_data += int.to_bytes(node.data_offset, 4)
u8_data += int.to_bytes(node.size, 4)
# Iterate over all file names and dump them. All file names are suffixed by a \x00 byte.
for file_name in self.file_name_list:
u8_data += str.encode(file_name) + b'\x00'
# Apply the extra padding we calculated earlier by padding to where the data offset begins.
while len(u8_data) < data_offset:
u8_data += b'\x00'
u8_data = _pad_bytes(u8_data, 64)
# Iterate all file data and dump it.
for file in self.file_data_list:
u8_data += file
u8_data += _pad_bytes(file, 32)
# Return the U8 archive.
return u8_data
@@ -185,69 +190,58 @@ def extract_u8(u8_data, output_folder) -> None:
u8_archive.load(u8_data)
# This variable stores the path of the directory we're currently processing.
current_dir = output_folder
# This variable stores the final nodes for every directory we've entered, and is used to handle the recursion of
# those directories to ensure that everything gets where it belongs.
directory_recursion = [0]
# Iterate over every node and extract the files and folders.
# This variable stores the order of directory nodes leading to the current working directory, to make sure that
# things get where they belong.
parent_dirs = [0]
for node in range(len(u8_archive.u8_node_list)):
# Code for a directory node. Second check just ensures we ignore the root node.
if u8_archive.u8_node_list[node].type == 256 and u8_archive.u8_node_list[node].name_offset != 0:
# The size value for a directory node is the position of the last node in this directory, with the root node
# counting as node 1.
# If the current node is below the end of the current directory, create this directory inside the previous
# current directory and make the current.
if node + 1 < directory_recursion[-1]:
# Code for a directory node (excluding the root node since that already exists).
if u8_archive.u8_node_list[node].type == 1 and u8_archive.u8_node_list[node].name_offset != 0:
if u8_archive.u8_node_list[node].data_offset == parent_dirs[-1]:
current_dir = current_dir.joinpath(u8_archive.file_name_list[node])
os.mkdir(current_dir)
# If the current node is beyond the end of the current directory, we've followed that path all the way down,
# so reset back to the root directory and put our new directory there.
elif node + 1 > directory_recursion[-1]:
current_dir = output_folder.joinpath(u8_archive.file_name_list[node])
os.mkdir(current_dir)
# This check is here just in case a directory ever ends with an empty directory and not a file.
elif node + 1 == directory_recursion[-1]:
current_dir = current_dir.parent
directory_recursion.pop()
# If the last node for the directory we just processed is new (which is always should be), add it to the
# recursion array.
if u8_archive.u8_node_list[node].size not in directory_recursion:
directory_recursion.append(u8_archive.u8_node_list[node].size)
current_dir.mkdir(exist_ok=True)
parent_dirs.append(node)
else:
# Go up until we're back at the correct level.
while u8_archive.u8_node_list[node].data_offset != parent_dirs[-1]:
parent_dirs.pop()
parent_dirs.append(node)
current_dir = output_folder
# Rebuild current working directory, and make sure all directories in the path exist.
for directory in parent_dirs:
current_dir = current_dir.joinpath(u8_archive.file_name_list[directory])
current_dir.mkdir(exist_ok=True)
# Code for a file node.
elif u8_archive.u8_node_list[node].type == 0:
# Write out the file to the current directory.
output_file = open(current_dir.joinpath(u8_archive.file_name_list[node]), "wb")
output_file.write(u8_archive.file_data_list[node])
output_file.close()
# If this file is the final node for the current directory, pop() the recursion array and set the current
# directory to the parent of the previous current.
if node + 1 in directory_recursion:
current_dir = current_dir.parent
directory_recursion.pop()
# Code for a totally unrecognized node type, which should not happen.
elif u8_archive.u8_node_list[node].type != 0 and u8_archive.u8_node_list[node].type != 256:
raise ValueError("A node with an invalid type (" + str(u8_archive.u8_node_list[node].type) + ") was"
"found!")
open(current_dir.joinpath(u8_archive.file_name_list[node]), "wb").write(u8_archive.file_data_list[node])
# Handle an invalid node type.
elif u8_archive.u8_node_list[node].type != 0 and u8_archive.u8_node_list[node].type != 1:
raise ValueError("A node with an invalid type (" + str(u8_archive.u8_node_list[node].type) + ") was found!")
def _pack_u8_dir(u8_archive: U8Archive, current_path, node_count, name_offset):
def _pack_u8_dir(u8_archive: U8Archive, current_path, node_count, parent_node):
# First, get the list of everything in current path.
root_list = os.listdir(current_path)
file_list = []
dir_list = []
# Create separate lists of the files and directories in the current directory so that we can handle the files first.
# noinspection PyTypeChecker
root_list.sort(key=str.lower)
for path in root_list:
if os.path.isfile(current_path.joinpath(path)):
file_list.append(path)
elif os.path.isdir(current_path.joinpath(path)):
dir_list.append(path)
# noinspection PyTypeChecker
file_list.sort(key=str.lower)
# noinspection PyTypeChecker
dir_list.sort(key=str.lower)
# For files, read their data into the file data list, add their name into the file name list, then calculate the
# offset for their file name and create a new U8Node() for them.
# offset for their file name and create a new U8Node() for them. -1 values are temporary and are set during dumping.
for file in file_list:
node_count += 1
u8_archive.file_name_list.append(file)
u8_archive.file_data_list.append(open(current_path.joinpath(file), "rb").read())
u8_archive.u8_node_list.append(_U8Node(0, name_offset, 0, len(u8_archive.file_data_list[-1])))
name_offset = name_offset + len(file) + 1 # Add 1 to accommodate the null byte at the end of the name.
u8_archive.u8_node_list.append(_U8Node(0, -1, -1, len(u8_archive.file_data_list[-1])))
# For directories, add their name to the file name list, add empty data to the file data list (since they obviously
# wouldn't have any), find the total number of files and directories inside the directory to calculate the final
# node included in it, then recursively call this function again on that directory to process it.
@@ -256,12 +250,11 @@ def _pack_u8_dir(u8_archive: U8Archive, current_path, node_count, name_offset):
u8_archive.file_name_list.append(directory)
u8_archive.file_data_list.append(b'')
max_node = node_count + sum(1 for _ in current_path.joinpath(directory).rglob('*'))
u8_archive.u8_node_list.append(_U8Node(256, name_offset, 0, max_node))
name_offset = name_offset + len(directory) + 1 # Add 1 to accommodate the null byte at the end of the name.
u8_archive, node_count, name_offset = _pack_u8_dir(u8_archive, current_path.joinpath(directory), node_count,
name_offset)
u8_archive.u8_node_list.append(_U8Node(1, -1, parent_node, max_node))
u8_archive, node_count = _pack_u8_dir(u8_archive, current_path.joinpath(directory), node_count,
u8_archive.u8_node_list.index(u8_archive.u8_node_list[-1]))
# Return the U8Archive object, the current node we're on, and the current name offset.
return u8_archive, node_count, name_offset
return u8_archive, node_count
def pack_u8(input_path) -> bytes:
@@ -279,34 +272,19 @@ def pack_u8(input_path) -> bytes:
The data for the packed U8 archive.
"""
input_path = pathlib.Path(input_path)
if os.path.isdir(input_path):
if input_path.is_dir():
# Append empty entries at the start for the root node, and then create the root U8Node() object, using rglob()
# to read the total count of files and directories that will be packed so that we can add the total node count.
u8_archive = U8Archive()
u8_archive.file_name_list.append("")
u8_archive.file_data_list.append(b'')
u8_archive.u8_node_list.append(_U8Node(256, 0, 0, sum(1 for _ in input_path.rglob('*')) + 1))
u8_archive.u8_node_list.append(_U8Node(1, 0, 0, sum(1 for _ in input_path.rglob('*')) + 1))
# Call the private function _pack_u8_dir() on the root note, which will recursively call itself to pack every
# subdirectory and file. Discard node_count and name_offset since we don't care about them here, as they're
# really only necessary for the directory recursion.
u8_archive, _, _ = _pack_u8_dir(u8_archive, input_path, node_count=1, name_offset=1)
return u8_archive.dump()
elif os.path.isfile(input_path):
# Simple code to handle if a single file is provided as input. Not really sure *why* you'd do this, since the
# whole point of a U8 archive is to stitch files together, but it's here nonetheless.
with open(input_path, "rb") as f:
u8_archive = U8Archive()
file_name = input_path.name
file_data = f.read()
# Append blank file name for the root node.
u8_archive.file_name_list.append("")
u8_archive.file_name_list.append(file_name)
# Append blank data for the root node.
u8_archive.file_data_list.append(b'')
u8_archive.file_data_list.append(file_data)
# Append generic U8Node for the root, followed by the actual file's node.
u8_archive.u8_node_list.append(_U8Node(256, 0, 0, 2))
u8_archive.u8_node_list.append(_U8Node(0, 1, 0, len(file_data)))
u8_archive, _ = _pack_u8_dir(u8_archive, input_path, node_count=1, parent_node=0)
return u8_archive.dump()
elif input_path.is_file():
raise ValueError("This does not appear to be a directory.")
else:
raise FileNotFoundError("Input file/directory: \"" + str(input_path) + "\" does not exist!")
raise FileNotFoundError("Input directory: \"" + str(input_path) + "\" does not exist!")

View File

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

View File

@@ -3,8 +3,12 @@
from .content import *
from .crypto import *
from .emunand import *
from .iospatcher import *
from .nus import *
from .sys import *
from .ticket import *
from .title import *
from .tmd import *
from .util import *
from .wad import *

View File

@@ -10,7 +10,8 @@ vwii_key = '30bfc76e7c19afbb23163330ced7c28d'
def get_common_key(common_key_index) -> bytes:
"""
Gets the specified Wii Common Key based on the index provided.
Gets the specified Wii Common Key based on the index provided. If an invalid common key index is provided, this
function falls back on always returning key 0 (the Common Key).
Possible values for common_key_index: 0: Common Key, 1: Korean Key, 2: vWii Key
@@ -32,5 +33,5 @@ def get_common_key(common_key_index) -> bytes:
case 2:
common_key_bin = binascii.unhexlify(vwii_key)
case _:
raise ValueError("The common key index provided, " + str(common_key_index) + ", does not exist.")
common_key_bin = binascii.unhexlify(common_key)
return common_key_bin

View File

@@ -3,9 +3,11 @@
#
# See https://wiibrew.org/wiki/Title for details about how titles are formatted
import binascii
import io
import hashlib
from typing import List
from dataclasses import dataclass as _dataclass
from ..types import _ContentRecord
from ..shared import _pad_bytes, _align_value
from .crypto import decrypt_content, encrypt_content
@@ -84,7 +86,7 @@ class ContentRegion:
content_region_data = b''
for content in self.content_list:
# If this isn't the first content, pad the whole region to 64 bytes before the next one.
if content_region_data is not b'':
if content_region_data != b'':
content_region_data = _pad_bytes(content_region_data, 64)
# Calculate padding after this content before the next one.
padding_bytes = 0
@@ -107,6 +109,10 @@ class ContentRegion:
"""
Gets an individual content from the content region based on the provided index, in encrypted form.
This uses the content index, which is the value tied to each content and used as the IV for encryption, rather
than the literal index in the array of content, because sometimes the contents end up out of order in a WAD
while still retaining the original indices.
Parameters
----------
index : int
@@ -117,7 +123,17 @@ class ContentRegion:
bytes
The encrypted content listed in the content record.
"""
content_enc = self.content_list[index]
# Get a list of the current content indices, so we can make sure the target one exists. Doing it this way
# ensures we can find the target, even if the highest content index is greater than the highest literal index.
current_indices = []
for record in self.content_records:
current_indices.append(record.index)
if index not in current_indices:
raise ValueError("You are trying to get the content at index " + str(index) + ", but no content with that "
"index exists!")
# This is the literal index in the list of content that we're going to get.
target_index = current_indices.index(index)
content_enc = self.content_list[target_index]
return content_enc
def get_enc_content_by_cid(self, cid: int) -> bytes:
@@ -127,23 +143,23 @@ class ContentRegion:
Parameters
----------
cid : int
The Content ID of the content you want to get. Expected to be in decimal form.
The Content ID of the content you want to get. Expected to be in decimal form, not hex.
Returns
-------
bytes
The encrypted content listed in the content record.
"""
# Find the index of the requested Content ID.
content_index = None
for content in self.content_records:
if content.content_id == cid:
content_index = content.index
# If finding a matching ID was unsuccessful, that means that no content with that ID is in the TMD, so
# return a Value Error.
if content_index is None:
raise ValueError("The Content ID requested does not exist in the TMD's content records.")
# Call get_enc_content_by_index() using the index we just found.
# Get a list of the current Content IDs, so we can make sure the target one exists.
content_ids = []
for record in self.content_records:
content_ids.append(record.content_id)
if cid not in content_ids:
raise ValueError("You are trying to get a content with Content ID " + str(cid) + ", but no content with "
"that ID exists!")
# Get the content index associated with the CID we now know exists.
target_index = content_ids.index(cid)
content_index = self.content_records[target_index].index
content_enc = self.get_enc_content_by_index(content_index)
return content_enc
@@ -158,68 +174,84 @@ class ContentRegion:
"""
return self.content_list
def get_content_by_index(self, index: int, title_key: bytes) -> bytes:
def get_content_by_index(self, index: int, title_key: bytes, skip_hash=False) -> bytes:
"""
Gets an individual content from the content region based on the provided index, in decrypted form.
This uses the content index, which is the value tied to each content and used as the IV for encryption, rather
than the literal index in the array of content, because sometimes the contents end up out of order in a WAD
while still retaining the original indices.
Parameters
----------
index : int
The index of the content you want to get.
The content index of the content you want to get.
title_key : bytes
The Title Key for the title the content is from.
skip_hash : bool, optional
Skip the hash check and return the content regardless of its hash. Defaults to false.
Returns
-------
bytes
The decrypted content listed in the content record.
"""
# Load the encrypted content at the specified index and then decrypt it with the Title Key.
# Get a list of the current content indices, so we can make sure the target one exists. Doing it this way
# ensures we can find the target, even if the highest content index is greater than the highest literal index.
current_indices = []
for record in self.content_records:
current_indices.append(record.index)
# This is the literal index in the list of content that we're going to get.
target_index = current_indices.index(index)
content_enc = self.get_enc_content_by_index(index)
content_dec = decrypt_content(content_enc, title_key, self.content_records[index].index,
self.content_records[index].content_size)
content_dec = decrypt_content(content_enc, title_key, index, self.content_records[target_index].content_size)
# Hash the decrypted content and ensure that the hash matches the one in its Content Record.
# If it does not, then something has gone wrong in the decryption, and an error will be thrown.
content_dec_hash = hashlib.sha1(content_dec).hexdigest()
content_record_hash = str(self.content_records[index].content_hash.decode())
content_record_hash = str(self.content_records[target_index].content_hash.decode())
# Compare the hash and throw a ValueError if the hash doesn't match.
if content_dec_hash != content_record_hash:
raise ValueError("Content hash did not match the expected hash in its record! The incorrect Title Key may "
"have been used!\n"
if skip_hash:
print("Ignoring hash mismatch for content index " + str(index))
else:
raise ValueError("Content hash did not match the expected hash in its record! The incorrect Title Key "
"may have been used!\n"
"Expected hash is: {}\n".format(content_record_hash) +
"Actual hash is: {}".format(content_dec_hash))
return content_dec
def get_content_by_cid(self, cid: int, title_key: bytes) -> bytes:
def get_content_by_cid(self, cid: int, title_key: bytes, skip_hash=False) -> bytes:
"""
Gets an individual content from the content region based on the provided Content ID, in decrypted form.
Parameters
----------
cid : int
The Content ID of the content you want to get. Expected to be in decimal form.
The Content ID of the content you want to get. Expected to be in decimal form, not hex.
title_key : bytes
The Title Key for the title the content is from.
skip_hash : bool, optional
Skip the hash check and return the content regardless of its hash. Defaults to false.
Returns
-------
bytes
The decrypted content listed in the content record.
"""
# Find the index of the requested Content ID.
content_index = None
for content in self.content_records:
if content.content_id == cid:
content_index = content.index
# If finding a matching ID was unsuccessful, that means that no content with that ID is in the TMD, so
# return a Value Error.
if content_index is None:
raise ValueError("The Content ID requested does not exist in the TMD's content records.")
# Call get_content_by_index() using the index we just found.
content_dec = self.get_content_by_index(content_index, title_key)
# Get a list of the current Content IDs, so we can make sure the target one exists.
content_ids = []
for record in self.content_records:
content_ids.append(record.content_id)
if cid not in content_ids:
raise ValueError("You are trying to get a content with Content ID " + str(cid) + ", but no content with "
"that ID exists!")
# Get the content index associated with the CID we now know exists.
target_index = content_ids.index(cid)
content_index = self.content_records[target_index].index
content_dec = self.get_content_by_index(content_index, title_key, skip_hash)
return content_dec
def get_contents(self, title_key: bytes) -> List[bytes]:
def get_contents(self, title_key: bytes, skip_hash=False) -> List[bytes]:
"""
Gets a list of all contents from the content region, in decrypted form.
@@ -227,6 +259,8 @@ class ContentRegion:
----------
title_key : bytes
The Title Key for the title the content is from.
skip_hash : bool, optional
Skip the hash check and return the content regardless of its hash. Defaults to false.
Returns
-------
@@ -236,19 +270,19 @@ class ContentRegion:
dec_contents: List[bytes] = []
# Iterate over every content, get the decrypted version of it, then add it to a list and return it.
for content in range(self.num_contents):
dec_contents.append(self.get_content_by_index(content, title_key))
dec_contents.append(self.get_content_by_index(content, title_key, skip_hash))
return dec_contents
def set_enc_content(self, enc_content: bytes, cid: int, index: int, content_type: int, content_size: int,
def add_enc_content(self, enc_content: bytes, cid: int, index: int, content_type: int, content_size: int,
content_hash: bytes) -> None:
"""
Sets the provided index to a new content with the provided Content ID. Hashes and size of the content are
set in the content record, with a new record being added if necessary.
Adds a new encrypted content to the ContentRegion, and adds the provided Content ID, index, content type,
content size, and content hash to a new record in the ContentRecord list.
Parameters
----------
enc_content : bytes
The new encrypted content to set.
The new encrypted content to add.
cid : int
The Content ID to assign the new content in the content record.
index : int
@@ -260,54 +294,120 @@ class ContentRegion:
content_hash : bytes
The hash of the new encrypted content when decrypted.
"""
# Save the number of contents currently in the content region and records.
num_contents = len(self.content_records)
# Check if a record already exists for this index. If it doesn't, create it.
if (index + 1) > num_contents:
# Ensure that you aren't attempting to create a gap before appending.
if (index + 1) > num_contents + 1:
raise ValueError("You are trying to set the content at position " + str(index) + ", but no content "
"exists at position " + str(index - 1) + "!")
self.content_records.append(_ContentRecord(cid, index, content_type, content_size, content_hash))
# If it does, reassign the values in it.
else:
self.content_records[index].content_id = cid
self.content_records[index].content_type = content_type
self.content_records[index].content_size = content_size
self.content_records[index].content_hash = content_hash
# Check if a content already occupies the provided index. If it does, reassign it to the new content, if it
# doesn't, then append a new entry.
if (index + 1) > num_contents:
# Check to make sure this isn't reusing an already existing Content ID or index first.
for record in self.content_records:
if record.content_id == cid:
raise ValueError("Content with a Content ID of " + str(cid) + " already exists!")
elif record.index == index:
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().
self.content_list.append(enc_content)
else:
self.content_list[index] = enc_content
self.content_records.append(_ContentRecord(cid, index, content_type, content_size, content_hash))
def set_content(self, dec_content: bytes, cid: int, index: int, content_type: int, title_key: bytes) -> None:
def add_content(self, dec_content: bytes, cid: int, index: int, content_type: int, title_key: bytes) -> None:
"""
Sets the provided index to a new content with the provided Content ID. Hashes and size of the content are
set in the content record, with a new record being added if necessary.
Adds a new decrypted content to the ContentRegion, and adds the provided Content ID, index, content type,
content size, and content hash to a new record in the ContentRecord list.
This first gets the content hash and size from the provided data, and then encrypts the content with the
provided Title Key before adding it to the ContentRegion.
Parameters
----------
dec_content : bytes
The new decrypted content to set.
The new decrypted content to add.
cid : int
The Content ID to assign the new content in the content record.
index : int
The index to place the new content at.
content_type : int
The type of the new content.
title_key : bytes
The Title Key that matches the other content in the ContentRegion.
"""
content_size = len(dec_content)
content_hash = str.encode(hashlib.sha1(dec_content).hexdigest())
enc_content = encrypt_content(dec_content, title_key, index)
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,
content_type: int = None) -> None:
"""
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
specified, but if it isn't than the current values are preserved.
This uses the content index, which is the value tied to each content and used as the IV for encryption, rather
than the literal index in the array of content, because sometimes the contents end up out of order in a WAD
while still retaining the original indices.
Parameters
----------
enc_content : bytes
The new encrypted content to set.
index : int
The target content index to set the new content at.
content_size : int
The size of the new encrypted content when decrypted.
content_hash : bytes
The hash of the new encrypted content when decrypted.
cid : int, optional
The Content ID to assign the new content in the content record. Current value will be preserved if not set.
content_type : int, optional
The type of the new content. Current value will be preserved if not set.
"""
# Get a list of the current content indices, so we can make sure the target one exists. Doing it this way
# ensures we can find the target, even if the highest content index is greater than the highest literal index.
current_indices = []
for record in self.content_records:
current_indices.append(record.index)
if index not in current_indices:
raise ValueError("You are trying to set the content at index " + str(index) + ", but no content with that "
"index currently exists!")
# This is the literal index in the list of content/content records that we're going to change.
target_index = current_indices.index(index)
# Reassign the values, but only set the optional ones if they were passed.
self.content_records[target_index].content_size = content_size
self.content_records[target_index].content_hash = content_hash
if cid is not None:
self.content_records[target_index].content_id = cid
if content_type is not None:
self.content_records[target_index].content_type = content_type
# Add blank entries to the list to ensure that its length matches the length of the content record list.
while len(self.content_list) < len(self.content_records):
self.content_list.append(b'')
self.content_list[target_index] = enc_content
def set_content(self, dec_content: bytes, index: int, title_key: bytes, cid: int = None,
content_type: int = None) -> None:
"""
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
type can also be specified, but if it isn't than the current values are preserved.
The provided Title Key is used to encrypt the content so that it can be set in the ContentRegion.
Parameters
----------
dec_content : bytes
The new decrypted content to set.
index : int
The index to place the new content at.
title_key : bytes
The Title Key that matches the new decrypted content.
cid : int
The Content ID to assign the new content in the content record.
content_type : int
The type of the new content.
"""
# Store the size of the new content.
dec_content_size = len(dec_content)
content_size = len(dec_content)
# Calculate the hash of the new content.
dec_content_hash = str.encode(hashlib.sha1(dec_content).hexdigest())
content_hash = str.encode(hashlib.sha1(dec_content).hexdigest())
# Encrypt the content using the provided Title Key and index.
enc_content = encrypt_content(dec_content, title_key, index)
# Pass values to set_enc_content()
self.set_enc_content(enc_content, cid, index, content_type, dec_content_size, dec_content_hash)
self.set_enc_content(enc_content, index, content_size, content_hash, cid, content_type)
def load_enc_content(self, enc_content: bytes, index: int) -> None:
"""
@@ -315,6 +415,10 @@ class ContentRegion:
it matches the record at that index. Not recommended for most use cases, use decrypted content and
load_content() instead.
This uses the content index, which is the value tied to each content and used as the IV for encryption, rather
than the literal index in the array of content, because sometimes the contents end up out of order in a WAD
while still retaining the original indices.
Parameters
----------
enc_content : bytes
@@ -322,18 +426,30 @@ class ContentRegion:
index : int
The content index to load the content at.
"""
if (index + 1) > len(self.content_records) or len(self.content_records) == 0:
raise IndexError("No content records have been loaded, or that index is higher than the highest entry in "
"the content records.")
if (index + 1) > len(self.content_list):
self.content_list.append(enc_content)
else:
self.content_list[index] = enc_content
# Get a list of the current content indices, so we can make sure the target one exists. Doing it this way
# ensures we can find the target, even if the highest content index is greater than the highest literal index.
current_indices = []
for record in self.content_records:
current_indices.append(record.index)
if index not in current_indices:
raise ValueError("You are trying to load the content at index " + str(index) + ", but no content with that "
"index currently exists! Make sure the correct content records have been loaded.")
# Add blank entries to the list to ensure that its length matches the length of the content record list.
while len(self.content_list) < len(self.content_records):
self.content_list.append(b'')
# This is the literal index in the list of content/content records that we're going to change.
target_index = current_indices.index(index)
self.content_list[target_index] = enc_content
def load_content(self, dec_content: bytes, index: int, title_key: bytes) -> None:
"""
Loads the provided decrypted content into the content region at the specified index, but first checks to make
sure it matches the record at that index before loading. This content will be encrypted when loaded.
Loads the provided decrypted content into the ContentRegion at the specified index, but first checks to make
sure that it matches the corresponding record. This content will then be encrypted using the provided Title Key
before being loaded.
This uses the content index, which is the value tied to each content and used as the IV for encryption, rather
than the literal index in the array of content, because sometimes the contents end up out of order in a WAD
while still retaining the original indices.
Parameters
----------
@@ -344,19 +460,136 @@ class ContentRegion:
title_key: bytes
The Title Key that matches the decrypted content.
"""
# Make sure that content records exist and that the provided index exists in them.
if (index + 1) > len(self.content_records) or len(self.content_records) == 0:
raise IndexError("No content records have been loaded, or that index is higher than the highest entry in "
"the content records.")
# Get a list of the current content indices, so we can make sure the target one exists. Doing it this way
# ensures we can find the target, even if the highest content index is greater than the highest literal index.
current_indices = []
for record in self.content_records:
current_indices.append(record.index)
if index not in current_indices:
raise ValueError("You are trying to load the content at index " + str(index) + ", but no content with that "
"index currently exists! Make sure the correct content records have been loaded.")
# This is the literal index in the list of content/content records that we're going to change.
target_index = current_indices.index(index)
# Check the hash of the content against the hash stored in the record to ensure it matches.
content_hash = hashlib.sha1(dec_content).hexdigest()
if content_hash != self.content_records[index].content_hash.decode():
if content_hash != self.content_records[target_index].content_hash.decode():
raise ValueError("The decrypted content provided does not match the record at the provided index. \n"
"Expected hash is: {}\n".format(self.content_records[index].content_hash.decode()) +
"Actual hash is: {}".format(content_hash))
# Add blank entries to the list to ensure that its length matches the length of the content record list.
while len(self.content_list) < len(self.content_records):
self.content_list.append(b'')
# If the hash matches, encrypt the content and set it where it belongs.
# This uses the index from the content records instead of just the index given, because there are some strange
# circumstances where the actual index in the array and the assigned content index don't match up, and this
# needs to accommodate that. Seems to only apply to custom WADs ? (Like cIOS WADs?)
enc_content = encrypt_content(dec_content, title_key, index)
if (index + 1) > len(self.content_list):
self.content_list.append(enc_content)
self.content_list[target_index] = enc_content
@_dataclass
class _SharedContentRecord:
"""
A _SharedContentRecord object used to store the data of a specific content stored in /shared1/. Private class used
by the content module.
Attributes
----------
shared_id : str
The incremental ID used to store the shared content.
content_hash : bytes
The SHA-1 hash of the shared content.
"""
shared_id: str
content_hash: bytes
class SharedContentMap:
"""
A SharedContentMap object to parse and edit the content.map file stored in /shared1/ on the Wii's NAND. This file is
used to keep track of all shared contents installed on the console.
Attributes
----------
shared_records : List[_SharedContentRecord]
The shared content records stored in content.map.
"""
def __init__(self):
self.shared_records: List[_SharedContentRecord] = []
def load(self, content_map: bytes) -> None:
"""
Loads the raw content map and parses the records in it.
Parameters
----------
content_map : bytes
The data of a content.map file.
"""
# Sanity check to ensure the length is divisible by 28 bytes. If it isn't, then it is malformed.
if (len(content_map) % 28) != 0:
raise ValueError("The provided content map appears to be corrupted!")
entry_count = len(content_map) // 28
with io.BytesIO(content_map) as map_data:
for i in range(entry_count):
shared_id = str(map_data.read(8).decode())
content_hash = binascii.hexlify(map_data.read(20))
self.shared_records.append(_SharedContentRecord(shared_id, content_hash))
def dump(self) -> bytes:
"""
Dumps the SharedContentMap object back into a content.map file.
Returns
-------
bytes
The raw data of the content.map file.
"""
map_data = b''
for record in self.shared_records:
map_data += record.shared_id.encode()
map_data += binascii.unhexlify(record.content_hash)
return map_data
def add_content(self, content_hash: str | bytes) -> str:
"""
Adds a new shared content SHA-1 hash to the content map and returns the file name assigned to that hash.
Parameters
----------
content_hash : str, bytes
The SHA-1 hash of the new shared content.
Returns
-------
str
The filename assigned to the provided content hash.
"""
if type(content_hash) is bytes:
# This catches the format b'GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG'
if len(content_hash) == 40:
content_hash_converted = content_hash
# This catches the format
# b'\xGG\xGG\xGG\xGG\xGG\xGG\xGG\xGG\xGG\xGG\xGG\xGG\xGG\xGG\xGG\xGG\xGG\xGG\xGG\xGG'
elif len(content_hash) == 20:
content_hash_converted = binascii.hexlify(content_hash)
# If it isn't one of those lengths, it cannot possibly be valid, so reject it.
else:
self.content_list[index] = enc_content
raise ValueError("SHA-1 hash is not valid!")
# Allow for a string like "GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG"
elif type(content_hash) is str:
content_hash_converted = content_hash.encode()
# If the hash isn't bytes or a string, it isn't valid and is rejected.
else:
raise TypeError("SHA-1 hash type is not valid! It must be either type str or bytes.")
# Generate the file name for the new shared content by incrementing the highest name by 1. Thank you, Nintendo,
# for not just storing these as integers like you did EVERYWHERE else.
try:
maximum_index = int(self.shared_records[-1].shared_id, 16)
new_index = f"{maximum_index + 1:08X}".lower()
except IndexError:
new_index = f"{0:08X}"
self.shared_records.append(_SharedContentRecord(new_index, content_hash_converted))
return new_index

View File

@@ -7,7 +7,7 @@ from .commonkeys import get_common_key
from Crypto.Cipher import AES as _AES
def _convert_tid_to_iv(title_id: str) -> 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
# crypto functions.
title_key_iv = b''
@@ -17,7 +17,7 @@ def _convert_tid_to_iv(title_id: str) -> bytes:
title_key_iv = binascii.unhexlify(title_id)
# This catches the format b'\x00\x00\x00\x01\x00\x00\x00\x02'
elif len(title_id) == 8:
pass
title_key_iv = title_id
# If it isn't one of those lengths, it cannot possibly be valid, so reject it.
else:
raise ValueError("Title ID is not valid!")

View File

@@ -0,0 +1,161 @@
# "title/emunand.py" from libWiiPy by NinjaCheetah & Contributors
# https://github.com/NinjaCheetah/libWiiPy
#
# Code for handling setting up and modifying a Wii EmuNAND.
import os
import pathlib
import shutil
from .title import Title
from .content import SharedContentMap as _SharedContentMap
from .sys import UidSys as _UidSys
class EmuNAND:
"""
An EmuNAND object that allows for creating and modifying Wii EmuNANDs. Requires the path to the root of the
EmuNAND, and can optionally take in a callback function to send logs to.
Parameters
----------
emunand_root : str, pathlib.Path
The path to the EmuNAND root directory.
callback : function
A callback function to send EmuNAND logs to.
Attributes
----------
emunand_root : pathlib.Path
The path to the EmuNAND root directory.
"""
def __init__(self, emunand_root: str | pathlib.Path, callback: callable = None):
self.emunand_root = pathlib.Path(emunand_root)
self.log = callback if callback is not None else None
self.import_dir = self.emunand_root.joinpath("import")
self.meta_dir = self.emunand_root.joinpath("meta")
self.shared1_dir = self.emunand_root.joinpath("shared1")
self.shared2_dir = self.emunand_root.joinpath("shared2")
self.sys_dir = self.emunand_root.joinpath("sys")
self.ticket_dir = self.emunand_root.joinpath("ticket")
self.title_dir = self.emunand_root.joinpath("title")
self.tmp_dir = self.emunand_root.joinpath("tmp")
self.wfs_dir = self.emunand_root.joinpath("wfs")
self.import_dir.mkdir(exist_ok=True)
self.meta_dir.mkdir(exist_ok=True)
self.shared1_dir.mkdir(exist_ok=True)
self.shared2_dir.mkdir(exist_ok=True)
self.sys_dir.mkdir(exist_ok=True)
self.ticket_dir.mkdir(exist_ok=True)
self.title_dir.mkdir(exist_ok=True)
self.tmp_dir.mkdir(exist_ok=True)
self.wfs_dir.mkdir(exist_ok=True)
def install_title(self, title: Title, skip_hash=False) -> None:
"""
Install the provided Title object to the EmuNAND. This mimics a real WAD installation done by ES.
This will create some system files required if they do not exist, but note that this alone is not enough for
a working EmuNAND, other than for Dolphin which can fill in the gaps.
Parameters
----------
title : libWiiPy.title.Title
The loaded Title object to install.
skip_hash : bool, optional
Skip the hash check and install the title regardless of its hashes. Defaults to false.
"""
# 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_lower = title.tmd.title_id[8:]
# Tickets are installed as <tid_lower>.tik in /ticket/<tid_upper>/
ticket_dir = self.ticket_dir.joinpath(tid_upper)
ticket_dir.mkdir(exist_ok=True)
open(ticket_dir.joinpath(tid_lower + ".tik"), "wb").write(title.wad.get_ticket_data())
# The TMD and normal contents are installed to /title/<tid_upper>/<tid_lower>/content/, with the tmd being named
# title.tmd and the contents being named <cid>.app.
title_dir = self.title_dir.joinpath(tid_upper)
title_dir.mkdir(exist_ok=True)
title_dir = title_dir.joinpath(tid_lower)
title_dir.mkdir(exist_ok=True)
content_dir = title_dir.joinpath("content")
if content_dir.exists():
shutil.rmtree(content_dir) # Clear the content directory so old contents aren't left behind.
content_dir.mkdir(exist_ok=True)
open(content_dir.joinpath("title.tmd"), "wb").write(title.wad.get_tmd_data())
for content_file in range(0, title.tmd.num_contents):
if title.tmd.content_records[content_file].content_type == 1:
content_file_name = f"{title.tmd.content_records[content_file].content_id:08X}".lower()
open(content_dir.joinpath(content_file_name + ".app"), "wb").write(
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.
# 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 = _SharedContentMap()
existing_hashes = []
if content_map_path.exists():
content_map.load(open(content_map_path, "rb").read())
for record in content_map.shared_records:
existing_hashes.append(record.content_hash)
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_hash not in existing_hashes:
content_file_name = content_map.add_content(title.tmd.content_records[content_file].content_hash)
open(self.shared1_dir.joinpath(content_file_name + ".app"), "wb").write(
title.get_content_by_index(content_file, skip_hash=skip_hash))
open(self.shared1_dir.joinpath("content.map"), "wb").write(content_map.dump())
# The "footer" or meta file is installed as title.met in /meta/<tid_upper>/<tid_lower>/. Only write this if meta
# is not nothing.
meta_data = title.wad.get_meta_data()
if meta_data != b'':
meta_dir = self.meta_dir.joinpath(tid_upper)
meta_dir.mkdir(exist_ok=True)
meta_dir = meta_dir.joinpath(tid_lower)
meta_dir.mkdir(exist_ok=True)
open(meta_dir.joinpath("title.met"), "wb").write(title.wad.get_meta_data())
# Ensure we have a uid.sys file created.
uid_sys_path = self.sys_dir.joinpath("uid.sys")
uid_sys = _UidSys()
if not uid_sys_path.exists():
uid_sys.create()
def uninstall_title(self, tid: str) -> None:
"""
Uninstall the Title with the specified Title ID from the EmuNAND. This will leave shared contents unmodified.
Parameters
----------
tid : str
The Title ID of the Title to uninstall.
"""
# Save the upper and lower portions of the Title ID, because these are used as target install directories.
tid_upper = tid[:8]
tid_lower = tid[8:]
if not self.title_dir.joinpath(tid_upper).joinpath(tid_lower).exists():
raise ValueError(f"Title with Title ID {tid} does not appear to be installed!")
# Begin by removing the Ticket, which is installed to /ticket/<tid_upper>/<tid_lower>.tik
if self.ticket_dir.joinpath(tid_upper).joinpath(tid_lower + ".tik").exists():
os.remove(self.ticket_dir.joinpath(tid_upper).joinpath(tid_lower + ".tik"))
# The TMD and contents are stored in /title/<tid_upper>/<tid_lower>/. Remove the TMD and all contents, but don't
# delete the entire directory if anything exists in data.
title_dir = self.title_dir.joinpath(tid_upper).joinpath(tid_lower)
if not title_dir.joinpath("data").exists():
shutil.rmtree(title_dir)
elif title_dir.joinpath("data").exists() and not os.listdir(title_dir.joinpath("data")):
shutil.rmtree(title_dir)
else:
# There are files in data, so we only want to delete the content directory.
shutil.rmtree(title_dir.joinpath("content"))
# On the off chance this title has a meta entry, delete that too.
if self.meta_dir.joinpath(tid_upper).joinpath(tid_lower).joinpath("title.met").exists():
shutil.rmtree(self.meta_dir.joinpath(tid_upper).joinpath(tid_lower))

View File

@@ -0,0 +1,252 @@
# "title/iospatcher.py" from libWiiPy by NinjaCheetah & Contributors
# https://github.com/NinjaCheetah/libWiiPy
#
# Module for applying patches to IOS WADs via a Title().
import io
from .title import Title
class IOSPatcher:
"""
An IOSPatcher object that allows for applying patches to IOS WADs loaded into Title objects.
Attributes
----------
title : Title
The loaded Title object to be patched.
es_module_index : int
The content index that ES resides in and where ES patches are applied.
dip_module_index : int
The content index that DIP resides in and where DIP patches are applied. -1 if DIP patches are not applied.
"""
def __init__(self):
self.title: Title = Title()
self.es_module_index: int = -1
self.dip_module_index: int = -1
def load(self, title: Title) -> None:
"""
Loads a Title object containing an IOS WAD and locates the content containing the ES module that needs to be
patched.
Parameters
----------
title : Title
A Title object containing the IOS to be patched.
"""
# Check to ensure that this Title contains IOS. IOS always has a TID high of 00000001, and any TID low after
# 00000002.
tid = title.tmd.title_id
if tid[:8] != "00000001" or tid[8:] == "00000001" or tid[8:] == "00000002":
raise ValueError("This Title does not contain an IOS! Cannot load Title for patching.")
# Now that we know this is IOS, we need to go ahead and check all of its contents until we find the one that
# contains the ES module, since that's what we're patching.
es_content_index = -1
for content in range(len(title.content.content_records)):
target_content = title.get_content_by_index(title.content.content_records[content].index)
es_offset = target_content.find(b'\x45\x53\x3A') # This is looking for "ES:"
if es_offset != -1:
es_content_index = title.content.content_records[content].index
break
# If we get here with no content index, then ES wasn't found. That probably means that this isn't IOS.
if es_content_index == -1:
raise Exception("ES module could not be found! Please ensure that this is an intact copy of an IOS.")
self.title = title
self.es_module_index = es_content_index
def dump(self) -> Title:
"""
Returns the patched Title object.
Returns
-------
Title
The patched Title object.
"""
return self.title
def patch_all(self) -> int:
"""
Applies all patches to patch in fakesigning, ES_Identify access, /dev/flash access, and the version downgrading
patch.
Returns
-------
int
The number of patches successfully applied.
"""
patch_count = 0
patch_count += self.patch_fakesigning()
patch_count += self.patch_es_identify()
patch_count += self.patch_nand_access()
patch_count += self.patch_version_downgrading()
return patch_count
def patch_fakesigning(self) -> int:
"""
Patches the trucha/fakesigning bug back into the IOS' ES module to allow it to accept fakesigned TMDs and
Tickets.
Returns
-------
int
The number of patches successfully applied.
"""
if self.es_module_index == -1:
raise Exception("No valid IOS is loaded! Patching cannot continue.")
target_content = self.title.get_content_by_index(self.es_module_index)
patch_count = 0
patch_sequences = [b'\x20\x07\x23\xa2', b'\x20\x07\x4b\x0b']
for sequence in patch_sequences:
start_offset = target_content.find(sequence)
if start_offset != -1:
with io.BytesIO(target_content) as content_data:
content_data.seek(start_offset + 1)
content_data.write(b'\x00')
content_data.seek(0)
target_content = content_data.read()
patch_count += 1
self.title.set_content(target_content, self.es_module_index)
return patch_count
def patch_es_identify(self) -> int:
"""
Patches the ability to call ES_Identify back into the IOS' ES module to allow for changing the permissions of a
title.
Returns
-------
int
The number of patches successfully applied.
"""
if self.es_module_index == -1:
raise Exception("No valid IOS is loaded! Patching cannot continue.")
target_content = self.title.get_content_by_index(self.es_module_index)
patch_count = 0
patch_sequence = b'\x28\x03\xd1\x23'
start_offset = target_content.find(patch_sequence)
if start_offset != -1:
with io.BytesIO(target_content) as content_data:
content_data.seek(start_offset + 2)
content_data.write(b'\x00\x00')
content_data.seek(0)
target_content = content_data.read()
patch_count += 1
self.title.set_content(target_content, self.es_module_index)
return patch_count
def patch_nand_access(self) -> int:
"""
Patches the ability to directly access /dev/flash back into the IOS' ES module to allow for raw access to the
Wii's filesystem.
Returns
-------
int
The number of patches successfully applied.
"""
if self.es_module_index == -1:
raise Exception("No valid IOS is loaded! Patching cannot continue.")
target_content = self.title.get_content_by_index(self.es_module_index)
patch_count = 0
patch_sequence = b'\x42\x8b\xd0\x01\x25\x66'
start_offset = target_content.find(patch_sequence)
if start_offset != -1:
with io.BytesIO(target_content) as content_data:
content_data.seek(start_offset + 2)
content_data.write(b'\xe0')
content_data.seek(0)
target_content = content_data.read()
patch_count += 1
self.title.set_content(target_content, self.es_module_index)
return patch_count
def patch_version_downgrading(self) -> int:
"""
Patches the ability to downgrade installed titles into IOS' ES module.
Returns
-------
int
The number of patches successfully applied.
"""
if self.es_module_index == -1:
raise Exception("No valid IOS is loaded! Patching cannot continue.")
target_content = self.title.get_content_by_index(self.es_module_index)
patch_count = 0
patch_sequence = b'\xd2\x01\x4e\x56'
start_offset = target_content.find(patch_sequence)
if start_offset != -1:
with io.BytesIO(target_content) as content_data:
content_data.seek(start_offset)
content_data.write(b'\xe0')
content_data.seek(0)
target_content = content_data.read()
patch_count += 1
self.title.set_content(target_content, self.es_module_index)
return patch_count
def patch_drive_inquiry(self) -> int:
"""
Patches out IOS' drive inquiry on startup, allowing IOS to load without a disc drive. Only required/useful if
you do not have a disc drive connected to your console.
This drive inquiry patch is EXPERIMENTAL, and may introduce unexpected side effects on some consoles.
Returns
-------
int
The number of patches successfully applied.
"""
if self.es_module_index == -1:
raise Exception("No valid IOS is loaded! Patching cannot continue.")
# This patch is applied to the DIP module rather than to ES, so we need to search the contents for the right one
# first.
for content in range(len(self.title.content.content_records)):
target_content = self.title.get_content_by_index(self.title.content.content_records[content].index)
dip_offset = target_content.find(b'\x44\x49\x50\x3a') # This is looking for "DIP:"
if dip_offset != -1:
self.dip_module_index = self.title.content.content_records[content].index
break
# If we get here with no content index, then DIP wasn't found. That probably means that this isn't IOS.
if self.dip_module_index == -1:
raise Exception("DIP module could not be found! Please ensure that this is an intact copy of an IOS.")
target_content = self.title.get_content_by_index(self.dip_module_index)
patch_count = 0
patch_sequence = b'\x49\x4c\x23\x90\x68\x0a' # 49 4c 23 90 68 0a
start_offset = target_content.find(patch_sequence)
if start_offset != -1:
with io.BytesIO(target_content) as content_data:
content_data.seek(start_offset)
content_data.write(b'\x20\x00\xe5\x38')
content_data.seek(0)
target_content = content_data.read()
patch_count += 1
self.title.set_content(target_content, self.dip_module_index)
return patch_count

124
src/libWiiPy/title/sys.py Normal file
View File

@@ -0,0 +1,124 @@
# "title/sys.py" from libWiiPy by NinjaCheetah & Contributors
# https://github.com/NinjaCheetah/libWiiPy
#
# See https://wiibrew.org/wiki//sys/uid.sys for information about uid.sys.
import io
import binascii
from typing import List
from dataclasses import dataclass as _dataclass
@_dataclass
class _UidSysEntry:
"""
A _UidSysEntry object used to store an entry in uid.sys. Private class used by the sys module.
Attributes
----------
title_id : str
The Title ID of the title this entry corresponds with.
uid : int
The UID assigned to the title this entry corresponds with.
"""
title_id: str
uid: int
class UidSys:
"""
A UidSys object to parse and edit the uid.sys file stored in /sys/ on the Wii's NAND. This file is used to track all
the titles installed on the console.
Attributes
----------
uid_entries : List[_UidSysEntry]
The entries stored in the uid.sys file.
"""
def __init__(self):
self.uid_entries: List[_UidSysEntry] = []
def load(self, uid_sys: bytes) -> None:
"""
Loads the raw data of uid.sys and parses it into a list of entries.
Parameters
----------
uid_sys : bytes
The data of a uid.sys file.
"""
# Sanity check to ensure the length is divisible by 12 bytes. If it isn't, then it is malformed.
if (len(uid_sys) % 12) != 0:
raise ValueError("The provided uid.sys appears to be corrupted!")
entry_count = len(uid_sys) // 12
with io.BytesIO(uid_sys) as uid_data:
for i in range(entry_count):
title_id = binascii.hexlify(uid_data.read(8)).decode()
uid_data.seek(uid_data.tell() + 2)
uid = int.from_bytes(uid_data.read(2))
self.uid_entries.append(_UidSysEntry(title_id, uid))
def dump(self) -> bytes:
"""
Dumps the UidSys object back into a uid.sys file.
Returns
-------
bytes
The raw data of the uid.sys file.
"""
uid_data = b''
for record in self.uid_entries:
uid_data += binascii.unhexlify(record.title_id.encode())
uid_data += b'\x00' * 2
uid_data += int.to_bytes(record.uid, 2)
return uid_data
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.
Parameters
----------
title_id : str, bytes
The Title ID to add.
Returns
-------
int
The UID assigned to the new Title ID.
"""
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'
elif len(title_id) == 8:
title_id_converted = binascii.hexlify(title_id).decode()
# If it isn't one of those lengths, it cannot possibly be valid, so reject it.
else:
raise ValueError("Title ID is not valid!")
# Allow for a string like "0000000100000002"
elif type(title_id) is str:
if len(title_id) != 16:
raise ValueError("Title ID is not valid!")
title_id_converted = title_id
else:
raise TypeError("Title ID type is not valid! It must be either type str or bytes.")
# Generate the new UID by incrementing the current highest UID by 1.
try:
new_uid = self.uid_entries[-1].uid + 1
except IndexError:
new_uid = 4096
self.uid_entries.append(_UidSysEntry(title_id_converted, new_uid))
return new_uid
def create(self) -> None:
"""
Creates a new uid.sys file and initializes it with the standard first entry of 1-2 with UID 4096. This allows
for setting up a uid.sys file without having to load an existing one.
"""
if len(self.uid_entries) != 0:
raise Exception("A uid.sys file appears to already exist!")
self.add("0000000100000002")

View File

@@ -9,6 +9,7 @@ import hashlib
from dataclasses import dataclass as _dataclass
from .crypto import decrypt_title_key
from typing import List
from .util import title_ver_standard_to_dec
@_dataclass
@@ -66,7 +67,6 @@ class Ticket:
self.ticket_id: bytes = b'' # Used as the IV when decrypting the title key for console-specific title installs.
self.console_id: int = 0 # ID of the console that the ticket was issued for.
self.title_id: bytes = b'' # TID/IV used for AES-CBC encryption.
self.title_id_str: str = "" # TID in string form for comparing against the TMD.
self.unknown1: bytes = b'' # Some unknown data, not always the same so reading it just in case.
self.title_version: int = 0 # Version of the ticket's associated title.
self.permitted_titles: bytes = b'' # Permitted titles mask
@@ -125,17 +125,12 @@ class Ticket:
# Title ID.
ticket_data.seek(0x1DC)
self.title_id = binascii.hexlify(ticket_data.read(8))
# Title ID (as a string).
self.title_id_str = str(self.title_id.decode())
# Unknown data 1.
ticket_data.seek(0x1E4)
self.unknown1 = ticket_data.read(2)
# Title version.
ticket_data.seek(0x1E6)
title_version_high = int.from_bytes(ticket_data.read(1)) * 256
ticket_data.seek(0x1E7)
title_version_low = int.from_bytes(ticket_data.read(1))
self.title_version = title_version_high + title_version_low
self.title_version = int.from_bytes(ticket_data.read(2))
# Permitted titles mask.
ticket_data.seek(0x1E8)
self.permitted_titles = ticket_data.read(4)
@@ -198,10 +193,7 @@ class Ticket:
# Unknown data 1.
ticket_data += self.unknown1
# Title version.
title_version_high = round(self.title_version / 256)
ticket_data += int.to_bytes(title_version_high, 1)
title_version_low = self.title_version % 256
ticket_data += int.to_bytes(title_version_low, 1)
ticket_data += int.to_bytes(self.title_version, 2)
# Permitted titles mask.
ticket_data += self.permitted_titles
# Permit mask.
@@ -260,6 +252,27 @@ class Ticket:
except OverflowError:
raise Exception("An error occurred during fakesigning. Ticket could not be fakesigned!")
def get_is_fakesigned(self) -> bool:
"""
Checks the Ticket object to see if it is currently fakesigned. For a description of fakesigning, refer to the
fakesign() method.
Returns
-------
bool:
True if the Ticket is fakesigned, False otherwise.
See Also
--------
libWiiPy.title.ticket.Ticket.fakesign()
"""
if self.signature != b'\x00' * 256:
return False
test_hash = hashlib.sha1(self.dump()[320:]).hexdigest()
if test_hash[:2] != '00':
return False
return True
def get_title_id(self) -> str:
"""
Gets the Title ID of the ticket's associated title.
@@ -283,7 +296,7 @@ class Ticket:
See Also
--------
commonkeys.get_common_key
libWiiPy.title.commonkeys.get_common_key
"""
match self.common_key_index:
case 0:
@@ -307,7 +320,8 @@ class Ticket:
def set_title_id(self, title_id) -> None:
"""
Sets the Title ID of the title in the Ticket.
Sets the Title ID property of the Ticket. Recommended over setting the property directly because of input
validation.
Parameters
----------
@@ -316,5 +330,34 @@ class Ticket:
"""
if len(title_id) != 16:
raise ValueError("Invalid Title ID! Title IDs must be 8 bytes long.")
self.title_id_str = title_id
self.title_id = binascii.unhexlify(title_id)
self.title_id = title_id.encode()
def set_title_version(self, new_version: str | int) -> None:
"""
Sets the version of the title in the Ticket. Recommended over setting the data directly because of input
validation.
Accepts either standard form (vX.X) as a string or decimal form (vXXX) as an integer.
Parameters
----------
new_version : str, int
The new version of the title. See description for valid formats.
"""
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.
# If checks pass, convert to decimal form and set that as the title version.
version_str_split = new_version.split(".")
if len(version_str_split) != 2:
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:
raise ValueError("Title version is not valid! String version number cannot exceed v255.255.")
version_converted = title_ver_standard_to_dec(new_version, str(self.title_id.decode()))
self.title_version = version_converted
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.
if new_version > 65535:
raise ValueError("Title version is not valid! Integer version number cannot exceed v65535.")
self.title_version = new_version
else:
raise TypeError("Title version type is not valid! Type must be either integer or string.")

View File

@@ -3,10 +3,12 @@
#
# See https://wiibrew.org/wiki/Title for details about how titles are formatted
import math
from .content import ContentRegion
from .ticket import Ticket
from .tmd import TMD
from .wad import WAD
from .crypto import encrypt_title_key
class Title:
@@ -56,7 +58,7 @@ class Title:
self.content.load(self.wad.get_content_data(), self.tmd.content_records)
# Ensure that the Title IDs of the TMD and Ticket match before doing anything else. If they don't, throw an
# error because clearly something strange has gone on with the WAD and editing it probably won't work.
if self.tmd.title_id != self.ticket.title_id_str:
if self.tmd.title_id != str(self.ticket.title_id.decode()):
raise ValueError("The Title IDs of the TMD and Ticket in this WAD do not match. This WAD appears to be "
"invalid.")
@@ -74,6 +76,7 @@ class Title:
if self.tmd.title_id == "0000000100000001":
self.wad.wad_type = "ib"
# Dump the TMD and set it in the WAD.
self.tmd.content_records = self.content.content_records
self.wad.set_tmd_data(self.tmd.dump())
# Dump the Ticket and set it in the WAD.
self.wad.set_ticket_data(self.ticket.dump())
@@ -119,7 +122,8 @@ class Title:
def set_title_id(self, title_id: str) -> None:
"""
Sets the Title ID of the title in both the TMD and Ticket.
Sets the Title ID of the title in both the TMD and Ticket. This also re-encrypts the Title Key as the Title Key
is used as the IV for decrypting it.
Parameters
----------
@@ -129,9 +133,26 @@ class Title:
if len(title_id) != 16:
raise ValueError("Invalid Title ID! Title IDs must be 8 bytes long.")
self.tmd.set_title_id(title_id)
title_key_decrypted = self.ticket.get_title_key()
self.ticket.set_title_id(title_id)
title_key_encrypted = encrypt_title_key(title_key_decrypted, self.ticket.common_key_index, title_id)
self.ticket.title_key_enc = title_key_encrypted
def get_content_by_index(self, index: id) -> bytes:
def set_title_version(self, title_version: str | int) -> None:
"""
Sets the version of the title in both the TMD and Ticket.
Accepts either standard form (vX.X) as a string or decimal form (vXXX) as an integer.
Parameters
----------
title_version : str, int
The new version of the title. See description for valid formats.
"""
self.tmd.set_title_version(title_version)
self.ticket.set_title_version(title_version)
def get_content_by_index(self, index: id, skip_hash=False) -> bytes:
"""
Gets an individual content from the content region based on the provided index, in decrypted form.
@@ -139,19 +160,18 @@ class Title:
----------
index : int
The index of the content you want to get.
skip_hash : bool, optional
Skip the hash check and return the content regardless of its hash. Defaults to false.
Returns
-------
bytes
The decrypted content listed in the content record.
"""
# Load the Title Key from the Ticket.
title_key = self.ticket.get_title_key()
# Get the decrypted content and return it.
dec_content = self.content.get_content_by_index(index, title_key)
dec_content = self.content.get_content_by_index(index, self.ticket.get_title_key(), skip_hash)
return dec_content
def get_content_by_cid(self, cid: int) -> bytes:
def get_content_by_cid(self, cid: int, skip_hash=False) -> bytes:
"""
Gets an individual content from the content region based on the provided Content ID, in decrypted form.
@@ -159,71 +179,118 @@ class Title:
----------
cid : int
The Content ID of the content you want to get. Expected to be in decimal form.
skip_hash : bool, optional
Skip the hash check and return the content regardless of its hash. Defaults to false.
Returns
-------
bytes
The decrypted content listed in the content record.
"""
# Load the Title Key from the Ticket.
title_key = self.ticket.get_title_key()
# Get the decrypted content and return it.
dec_content = self.content.get_content_by_cid(cid, title_key)
dec_content = self.content.get_content_by_cid(cid, self.ticket.get_title_key(), skip_hash)
return dec_content
def set_enc_content(self, enc_content: bytes, cid: int, index: int, content_type: int, content_size: int,
content_hash: bytes) -> None:
def get_title_size(self) -> int:
"""
Sets the provided index to a new content with the provided Content ID. Hashes and size of the content are
set in the content record, with a new record being added if necessary. The TMD is also updated to match the new
records.
Gets the installed size of the title, including the TMD and Ticket, in bytes.
Returns
-------
int
The installed size of the title, in bytes.
"""
title_size = 0
# Dumping and measuring the TMD and Ticket this way to ensure that any changes to them are measured properly.
# Yes, the Ticket size should be a constant, but it's still good to check just in case.
title_size += len(self.tmd.dump())
title_size += len(self.ticket.dump())
# 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.
for record in self.content.content_records:
title_size += record.content_size
return title_size
def get_title_size_blocks(self) -> int:
"""
Gets the installed size of the title, including the TMD and Ticket, in the Wii's displayed "blocks" format.
1 Wii block is equal to 128KiB, and if any amount of a block is used, the entire block is considered used.
Returns
-------
int
The installed size of the title, in blocks.
"""
title_size_bytes = self.get_title_size()
blocks = math.ceil(title_size_bytes / 131072)
return blocks
def set_enc_content(self, enc_content: bytes, index: int, content_size: int, content_hash: bytes, cid: int = None,
content_type: int = None) -> None:
"""
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
specified, but if it isn't than the current values are preserved.
This uses the content index, which is the value tied to each content and used as the IV for encryption, rather
than the literal index in the array of content, because sometimes the contents end up out of order in a WAD
while still retaining the original indices.
This also updates the content records in the TMD after the content is set.
Parameters
----------
enc_content : bytes
The new encrypted content to set.
cid : int
The Content ID to assign the new content in the content record.
index : int
The index to place the new content at.
content_type : int
The type of the new content.
content_size : int
The size of the new encrypted content when decrypted.
content_hash : bytes
The hash of the new encrypted content when decrypted.
cid : int
The Content ID to assign the new content in the content record.
content_type : int
The type of the new content.
"""
# Set the encrypted content.
self.content.set_enc_content(enc_content, cid, index, content_type, content_size, content_hash)
self.content.set_enc_content(enc_content, index, content_size, content_hash, cid, content_type)
# Update the TMD to match.
self.tmd.content_records = self.content.content_records
def set_content(self, dec_content: bytes, cid: int, index: int, content_type: int) -> None:
def set_content(self, dec_content: bytes, index: int, cid: int = None, content_type: int = None) -> None:
"""
Sets the provided index to a new content with the provided Content ID. Hashes and size of the content are
set in the content record, with a new record being added if necessary. The Title Key is sourced from this
title's loaded ticket. The TMD is also updated to match the new records.
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
type can also be specified, but if it isn't than the current values are preserved.
This also updates the content records in the TMD after the content is set.
Parameters
----------
dec_content : bytes
The new decrypted content to set.
cid : int
The Content ID to assign the new content in the content record.
index : int
The index to place the new content at.
content_type : int
cid : int, optional
The Content ID to assign the new content in the content record.
content_type : int, optional
The type of the new content.
"""
# Set the decrypted content.
self.content.set_content(dec_content, cid, index, content_type, self.ticket.get_title_key())
self.content.set_content(dec_content, index, self.ticket.get_title_key(), cid, content_type)
# Update the TMD to match.
self.tmd.content_records = self.content.content_records
def load_content(self, dec_content: bytes, index: int) -> None:
"""
Loads the provided decrypted content into the content region at the specified index, but first checks to make
sure it matches the record at that index before loading. This content will be encrypted when loaded.
Loads the provided decrypted content into the ContentRegion at the specified index, but first checks to make
sure that it matches the corresponding record. This content will then be encrypted using the title's Title Key
before being loaded.
This uses the content index, which is the value tied to each content and used as the IV for encryption, rather
than the literal index in the array of content, because sometimes the contents end up out of order in a WAD
while still retaining the original indices.
Parameters
----------
@@ -249,3 +316,22 @@ class Title:
"""
self.tmd.fakesign()
self.ticket.fakesign()
def get_is_fakesigned(self):
"""
Checks the Title object to see if it is currently fakesigned. This ensures that both the TMD and Ticket are
fakesigned. For a description of fakesigning, refer to the fakesign() method.
Returns
-------
bool:
True if the Title is fakesigned, False otherwise.
See Also
--------
libWiiPy.title.title.Title.fakesign()
"""
if self.tmd.get_is_fakesigned and self.ticket.get_is_fakesigned():
return True
else:
return False

View File

@@ -8,7 +8,10 @@ import binascii
import hashlib
import struct
from typing import List
from enum import IntEnum
from ..types import _ContentRecord
from ..shared import _bitmask
from .util import title_ver_dec_to_standard, title_ver_standard_to_dec
class TMD:
@@ -42,14 +45,14 @@ class TMD:
self.ios_tid: str = "" # The Title ID of the IOS version the associated title runs on.
self.ios_version: int = 0 # The IOS version the associated title runs on.
self.title_id: str = "" # The Title ID of the associated title.
self.content_type: str = "" # The type of content contained within the associated title.
self.title_type: str = "" # The type of the associated title.
self.group_id: int = 0 # The ID of the publisher of the associated title.
self.region: int = 0 # The ID of the region of the associated title.
self.ratings: bytes = b'' # The parental controls rating of the associated title.
self.reserved1: bytes = b'' # Unknown data labeled "Reserved" on WiiBrew.
self.ipc_mask: bytes = b''
self.reserved2: bytes = b'' # Other "Reserved" data from WiiBrew.
self.access_rights: bytes = b''
self.access_rights: int = 0
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.
@@ -108,7 +111,7 @@ class TMD:
tmd_data.seek(0x194)
content_type_bin = tmd_data.read(4)
content_type_hex = binascii.hexlify(content_type_bin)
self.content_type = str(content_type_hex.decode())
self.title_type = str(content_type_hex.decode())
# Publisher of the title.
tmd_data.seek(0x198)
self.group_id = int.from_bytes(tmd_data.read(2))
@@ -128,17 +131,14 @@ class TMD:
# "Reserved" data 2.
tmd_data.seek(0x1C6)
self.reserved2 = tmd_data.read(18)
# Access rights of the title; DVD-video access and AHBPROT.
# Access rights of the title; DVD-video and AHB access.
tmd_data.seek(0x1D8)
self.access_rights = tmd_data.read(4)
self.access_rights = int.from_bytes(tmd_data.read(4))
# Version number straight from the TMD.
tmd_data.seek(0x1DC)
self.title_version = int.from_bytes(tmd_data.read(2))
# Calculate the converted version number by multiplying 0x1DC by 256 and adding 0x1DD.
tmd_data.seek(0x1DC)
title_version_high = int.from_bytes(tmd_data.read(1)) * 256
title_version_low = int.from_bytes(tmd_data.read(1))
self.title_version_converted = title_version_high + title_version_low
# 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.
tmd_data.seek(0x1DE)
self.num_contents = int.from_bytes(tmd_data.read(2))
@@ -189,7 +189,7 @@ class TMD:
# Title's Title ID.
tmd_data += binascii.unhexlify(self.title_id)
# Content type.
tmd_data += binascii.unhexlify(self.content_type)
tmd_data += binascii.unhexlify(self.title_type)
# Group ID.
tmd_data += int.to_bytes(self.group_id, 2)
# 2 bytes of zero for reasons.
@@ -205,7 +205,7 @@ class TMD:
# "Reserved" 2.
tmd_data += self.reserved2
# Access rights.
tmd_data += self.access_rights
tmd_data += int.to_bytes(self.access_rights, 4)
# Title version.
tmd_data += int.to_bytes(self.title_version, 2)
# Number of contents.
@@ -257,12 +257,33 @@ class TMD:
except OverflowError:
raise Exception("An error occurred during fakesigning. TMD could not be fakesigned!")
def get_is_fakesigned(self) -> bool:
"""
Checks the TMD object to see if it is currently fakesigned. For a description of fakesigning, refer to the
fakesign() method.
Returns
-------
bool:
True if the TMD is fakesigned, False otherwise.
See Also
--------
libWiiPy.title.tmd.TMD.fakesign()
"""
if self.signature != b'\x00' * 256:
return False
test_hash = hashlib.sha1(self.dump()[320:]).hexdigest()
if test_hash[:2] != '00':
return False
return True
def get_title_region(self) -> str:
"""
Gets the region of the TMD's associated title.
Can be one of several possible values:
'JAP', 'USA', 'EUR', 'WORLD', or 'KOR'.
'Japan', 'North America', 'Europe', 'World', or 'Korea'.
Returns
-------
@@ -271,29 +292,15 @@ class TMD:
"""
match self.region:
case 0:
return "JAP"
return "Japan"
case 1:
return "USA"
return "North America"
case 2:
return "EUR"
return "Europe"
case 3:
return "WORLD"
return "World"
case 4:
return "KOR"
def get_is_vwii_title(self) -> bool:
"""
Gets whether the TMD is designed for the vWii or not.
Returns
-------
bool
If the title is for vWii.
"""
if self.vwii == 1:
return True
else:
return False
return "Korea"
def get_title_type(self) -> str:
"""
@@ -307,8 +314,7 @@ class TMD:
str
The type of the title.
"""
title_id_high = self.title_id[:8]
match title_id_high:
match self.title_type:
case '00000001':
return "System"
case '00010000':
@@ -326,28 +332,40 @@ class TMD:
case _:
return "Unknown"
def get_content_type(self):
def get_content_type(self, content_index: int) -> str:
"""
Gets the type of content contained in the TMD's associated title.
Can be one of several possible values:
'Normal', 'Development/Unknown', 'Hash Tree', 'DLC', or 'Shared'
Parameters
----------
content_index : int
The index of the content you want the type of.
Returns
-------
str
The type of content.
"""
match self.content_type:
case '00000001':
# Get a list of the current content indices, so we can make sure the target one exists. Doing it this way
# ensures we can find the target, even if the highest content index is greater than the highest literal index.
current_indices = []
for record in self.content_records:
current_indices.append(record.index)
# This is the literal index in the list of content that we're going to get.
target_index = current_indices.index(content_index)
match self.content_records[target_index].content_type:
case 1:
return "Normal"
case '00000002':
case 2:
return "Development/Unknown"
case '00000003':
case 3:
return "Hash Tree"
case '00004001':
case 16385:
return "DLC"
case '00008001':
case 32769:
return "Shared"
case _:
return "Unknown"
@@ -372,9 +390,31 @@ class TMD:
raise IndexError("Invalid content record! TMD lists '" + str(self.num_contents - 1) +
"' contents but index was '" + str(record) + "'!")
class AccessFlags(IntEnum):
AHB = 0
DVD_VIDEO = 1
def get_access_right(self, flag: int) -> bool:
"""
Gets whether an access rights flag is enabled or not. This is done by checking the specified bit. Possible flags
and their corresponding bits are defined in the AccessFlags enum.
Parameters
----------
flag : int
The flag to check.
Returns
-------
bool
True if the flag is enabled, False otherwise.
"""
return bool(self.access_rights & _bitmask(flag))
def set_title_id(self, title_id) -> None:
"""
Sets the Title ID of the title in the ticket.
Sets the Title ID property of the TMD. Recommended over setting the property directly because of input
validation.
Parameters
----------
@@ -384,3 +424,37 @@ class TMD:
if len(title_id) != 16:
raise ValueError("Invalid Title ID! Title IDs must be 8 bytes long.")
self.title_id = title_id
def set_title_version(self, new_version: str | int) -> None:
"""
Sets the version of the title in the TMD. Recommended over setting the data directly because of input
validation.
Accepts either standard form (vX.X) as a string or decimal form (vXXX) as an integer.
Parameters
----------
new_version : str, int
The new version of the title. See description for valid formats.
"""
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.
# If checks pass, set that as the converted version, then convert to decimal form and set that as well.
version_str_split = new_version.split(".")
if len(version_str_split) != 2:
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:
raise ValueError("Title version is not valid! String version number cannot exceed v255.255.")
self.title_version_converted = new_version
version_converted = title_ver_standard_to_dec(new_version, self.title_id)
self.title_version = version_converted
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,
# then convert to standard form and set that as well.
if new_version > 65535:
raise ValueError("Title version is not valid! Integer version number cannot exceed v65535.")
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:
raise TypeError("Title version type is not valid! Type must be either integer or string.")

View File

@@ -0,0 +1,80 @@
# "title/util.py" from libWiiPy by NinjaCheetah & Contributors
# https://github.com/NinjaCheetah/libWiiPy
#
# General title-related utilities that don't fit within a specific module.
import math
from ..shared import _wii_menu_versions, _vwii_menu_versions
def title_ver_dec_to_standard(version: int, title_id: str, vwii: bool = False) -> str:
"""
Converts a title's version from decimal form (vXXX, the way the version is stored in the TMD/Ticket) to its standard
and human-readable form (vX.X). The Title ID is required as some titles handle this version differently from others.
For the System Menu, the returned version will include the region code (ex. 4.3U).
Parameters
----------
version : int
The version of the title, in decimal form.
title_id : str
The Title ID that the version is associated with.
vwii : bool
Whether this title is for the vWii or not. Only relevant for the System Menu.
Returns
-------
str
The version of the title, in standard form.
"""
version_out = ""
if title_id == "0000000100000002":
if vwii:
try:
version_out = list(_vwii_menu_versions.keys())[list(_vwii_menu_versions.values()).index(version)]
except ValueError:
version_out = ""
else:
try:
version_out = list(_wii_menu_versions.keys())[list(_wii_menu_versions.values()).index(version)]
except ValueError:
version_out = ""
else:
# For most channels, we need to get the floored value of version / 256 for the major version, and the version %
# 256 as the minor version. Minor versions > 9 are intended, as Nintendo themselves frequently used them.
version_upper = math.floor(version / 256)
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:
"""
Converts a title's version from its standard and human-readable form (vX.X) to its decimal form (vXXX, the way the
version is stored in the TMD/Ticket). The Title ID is required as some titles handle this version differently from
others. For the System Menu, the supplied version must include the region code (ex. 4.3U) for the conversion to
work correctly.
Parameters
----------
version : str
The version of the title, in standard form.
title_id : str
The Title ID that the version is associated with.
Returns
-------
int
The version of the title, in decimal form.
"""
version_out = 0
if title_id == "0000000100000002":
raise ValueError("The System Menu's version cannot currently be converted.")
else:
version_str_split = version.split(".")
version_upper = int(version_str_split[0]) * 256
version_lower = int(version_str_split[1])
version_out = version_upper + version_lower
return version_out

View File

@@ -103,13 +103,13 @@ class WAD:
# Calculate file offsets from sizes. Every section of the WAD is padded out to a multiple of 0x40.
# ====================================================================================
wad_cert_offset = self.wad_hdr_size
# crl isn't ever used, however an entry for its size exists in the header, so its calculated just in case.
# crl isn't ever used, however an entry for its size exists in the header, so it's calculated just in case.
wad_crl_offset = _align_value(wad_cert_offset + self.wad_cert_size)
wad_tik_offset = _align_value(wad_crl_offset + self.wad_crl_size)
wad_tmd_offset = _align_value(wad_tik_offset + self.wad_tik_size)
wad_content_offset = _align_value(wad_tmd_offset + self.wad_tmd_size)
# meta isn't guaranteed to be used, but some older SDK titles use it, and not reading it breaks things.
wad_meta_offset = _align_value(wad_tmd_offset + self.wad_tmd_size)
wad_content_offset = _align_value(wad_meta_offset + self.wad_meta_size)
wad_meta_offset = _align_value(wad_content_offset + self.wad_content_size)
# ====================================================================================
# Load data for each WAD section based on the previously calculated offsets.
# ====================================================================================
@@ -174,12 +174,12 @@ class WAD:
# Retrieve the TMD data and write it out.
wad_data += self.get_tmd_data()
wad_data = _pad_bytes(wad_data)
# Retrieve the meta/footer data and write it out.
wad_data += self.get_meta_data()
wad_data = _pad_bytes(wad_data)
# Retrieve the content data and write it out.
wad_data += self.get_content_data()
wad_data = _pad_bytes(wad_data)
# Retrieve the meta/footer data and write it out.
wad_data += self.get_meta_data()
wad_data = _pad_bytes(wad_data)
return wad_data
def get_wad_type(self) -> str:

View File

@@ -5,7 +5,8 @@
import unittest
from test_commonkeys import TestCommonKeys
from .title.commonkeys_test import *
from .title.nus_test import *
if __name__ == '__main__':
unittest.main()

0
test/title/__init__.py Normal file
View File

View File

@@ -1,4 +1,4 @@
# "test_commonkeys.py" from libWiiPy by NinjaCheetah & Contributors
# "commonkeys_test.py" from libWiiPy by NinjaCheetah & Contributors
# https://github.com/NinjaCheetah/libWiiPy
import unittest

67
test/title/nus_test.py Normal file
View File

@@ -0,0 +1,67 @@
# "nus_test.py" from libWiiPy by NinjaCheetah & Contributors
# https://github.com/NinjaCheetah/libWiiPy
import hashlib
import unittest
import libWiiPy
class TestNUSDownloads(unittest.TestCase):
def test_download_title(self):
title = libWiiPy.title.download_title("0000000100000002", 513)
title_hash = hashlib.sha1(title.dump_wad()).hexdigest()
self.assertEqual(title_hash, "c5e25fdb1ae6921597058b9f07045be0b003c550")
title = libWiiPy.title.download_title("0000000100000002", 513, wiiu_endpoint=True)
title_hash = hashlib.sha1(title.dump_wad()).hexdigest()
self.assertEqual(title_hash, "c5e25fdb1ae6921597058b9f07045be0b003c550")
def test_download_tmd(self):
tmd = libWiiPy.title.download_tmd("0000000100000002", 513)
tmd_hash = hashlib.sha1(tmd).hexdigest()
self.assertEqual(tmd_hash, "e8f9657d591b305e300c109b5641630aa4e2318b")
tmd = libWiiPy.title.download_tmd("0000000100000002", 513, wiiu_endpoint=True)
tmd_hash = hashlib.sha1(tmd).hexdigest()
self.assertEqual(tmd_hash, "e8f9657d591b305e300c109b5641630aa4e2318b")
with self.assertRaises(ValueError):
libWiiPy.title.download_tmd("TEST_STRING")
def test_download_ticket(self):
ticket = libWiiPy.title.download_ticket("0000000100000002")
ticket_hash = hashlib.sha1(ticket).hexdigest()
self.assertEqual(ticket_hash, "7076891f96ad3e4a6148a4a308e4a12fc72cc4b5")
ticket = libWiiPy.title.download_ticket("0000000100000002", wiiu_endpoint=True)
ticket_hash = hashlib.sha1(ticket).hexdigest()
self.assertEqual(ticket_hash, "7076891f96ad3e4a6148a4a308e4a12fc72cc4b5")
with self.assertRaises(ValueError):
libWiiPy.title.download_ticket("TEST_STRING")
def test_download_cert(self):
cert = libWiiPy.title.download_cert()
self.assertIsNotNone(cert)
cert = libWiiPy.title.download_cert(wiiu_endpoint=True)
self.assertIsNotNone(cert)
def test_download_content(self):
content = libWiiPy.title.download_content("0000000100000002", 150)
content_hash = hashlib.sha1(content).hexdigest()
self.assertEqual(content_hash, "1f10abe6517d29950aa04c71b264c18d204ed363")
content = libWiiPy.title.download_content("0000000100000002", 150, wiiu_endpoint=True)
content_hash = hashlib.sha1(content).hexdigest()
self.assertEqual(content_hash, "1f10abe6517d29950aa04c71b264c18d204ed363")
with self.assertRaises(ValueError):
libWiiPy.title.download_content("TEST_STRING", 150)
with self.assertRaises(ValueError):
libWiiPy.title.download_content("0000000100000002", -1)
def test_download_contents(self):
tmd = libWiiPy.title.TMD()
tmd.load(libWiiPy.title.download_tmd("0000000100000002"))
contents = libWiiPy.title.download_contents("0000000100000002", tmd)
self.assertIsNotNone(contents)
contents = libWiiPy.title.download_contents("0000000100000002", tmd, wiiu_endpoint=True)
self.assertIsNotNone(contents)
if __name__ == '__main__':
unittest.main()