mirror of
https://github.com/NinjaCheetah/libWiiPy.git
synced 2026-03-05 08:35:28 -05:00
Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
1ae649afac
|
|||
|
b782e5dea5
|
|||
|
894aa3a04b
|
|||
|
152a13fbe2
|
|||
|
72a8b9b6a6
|
|||
|
128f4a9303
|
|||
|
bab777b8b9
|
|||
|
fb87c2c58c
|
|||
|
6220821a2f
|
|||
|
580ba8526f
|
|||
|
7e308a35eb
|
|||
|
194b65c6d6
|
|||
|
cfd5abac7e
|
|||
|
7edf764768
|
|||
| 544e65a109 | |||
| bcbdd284e9 | |||
|
415af7b8b8
|
|||
|
f81398e854
|
|||
|
60975dc62d
|
|||
|
40e4459893
|
|||
|
5c56eabe9f
|
|||
|
9d26ff74ff
|
|||
|
18b54af091
|
|||
|
2d67f982dc
|
|||
|
d6e6352d0a
|
|||
|
7daba7ec86
|
|||
|
930e09828e
|
|||
|
a5ce7e9cd1
|
|||
|
76b773ee36
|
|||
|
817a2c9ac5
|
|||
|
102da808e6
|
|||
|
f7f67d3414
|
|||
|
39eecec864
|
|||
|
5f4fa8827c
|
|||
|
e70b9570de
|
20
README.md
20
README.md
@@ -1,22 +1,28 @@
|
|||||||

|

|
||||||
# libWiiPy
|
# libWiiPy
|
||||||
libWiiPy is a modern Python 3 library for handling the various files and formats found on the Wii. It aims to be simple to use, well maintained, and offer as many features as reasonably possible in one library, so that a newly-written Python program could 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!
|
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
|
# Features
|
||||||
This list will expand as libWiiPy is developed, but these features are currently available:
|
This list will expand as libWiiPy is developed, but these features are currently available:
|
||||||
- TMD and Ticket parsing (`.tmd`, `.tik`)
|
- TMD and Ticket parsing/editing (`.tmd`, `.tik`)
|
||||||
- Title content decryption, re-encryption
|
- Title parsing/editing, including content encryption/decryption
|
||||||
- Packing and unpacking WAD files (`.wad`)
|
- WAD file parsing/editing (`.wad`)
|
||||||
- Downloading titles from the NUS
|
- Downloading titles from the NUS
|
||||||
- Packing and unpacking U8 archives (`.app`, `.arc`)
|
- Packing and unpacking U8 archives (`.app`, `.arc`)
|
||||||
- Decompressing ASH files (`.ash`, both the standard variants and the variants found in My Pokémon Ranch)
|
- Decompressing ASH files (`.ash`, both the standard variants and the variants found in My Pokémon Ranch)
|
||||||
|
- 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
|
# 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.
|
The easiest way to get libWiiPy for your project is to install the latest version of the library from PyPI, as shown below.
|
||||||
```sh
|
```sh
|
||||||
pip install -U libWiiPy
|
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.
|
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
|
# 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!
|
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!
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
## Submodules
|
## Submodules
|
||||||
|
|
||||||
### libWiiPy.title.commonkeys module
|
### libWiiPy.title.commonkeys module
|
||||||
|
|
||||||
```{eval-rst}
|
```{eval-rst}
|
||||||
.. automodule:: libWiiPy.title.commonkeys
|
.. automodule:: libWiiPy.title.commonkeys
|
||||||
:members:
|
:members:
|
||||||
@@ -12,7 +11,6 @@
|
|||||||
```
|
```
|
||||||
|
|
||||||
### libWiiPy.title.content module
|
### libWiiPy.title.content module
|
||||||
|
|
||||||
```{eval-rst}
|
```{eval-rst}
|
||||||
.. automodule:: libWiiPy.title.content
|
.. automodule:: libWiiPy.title.content
|
||||||
:members:
|
:members:
|
||||||
@@ -21,55 +19,86 @@
|
|||||||
```
|
```
|
||||||
|
|
||||||
### libWiiPy.title.crypto module
|
### libWiiPy.title.crypto module
|
||||||
|
|
||||||
```{eval-rst}
|
```{eval-rst}
|
||||||
.. automodule:: libWiiPy.title.crypto
|
.. automodule:: libWiiPy.title.crypto
|
||||||
:members:
|
:members:
|
||||||
:undoc-members:
|
:undoc-members:
|
||||||
:show-inheritance:
|
: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}
|
```{eval-rst}
|
||||||
.. automodule:: libWiiPy.title.nus
|
.. automodule:: libWiiPy.title.nus
|
||||||
:members:
|
:members:
|
||||||
:undoc-members:
|
:undoc-members:
|
||||||
:show-inheritance:
|
: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}
|
```{eval-rst}
|
||||||
.. automodule:: libWiiPy.title.ticket
|
.. automodule:: libWiiPy.title.ticket
|
||||||
:members:
|
:members:
|
||||||
:undoc-members:
|
:undoc-members:
|
||||||
:show-inheritance:
|
:show-inheritance:
|
||||||
```
|
```
|
||||||
### libWiiPy.title.title module
|
|
||||||
|
|
||||||
|
### libWiiPy.title.title module
|
||||||
```{eval-rst}
|
```{eval-rst}
|
||||||
.. automodule:: libWiiPy.title.title
|
.. automodule:: libWiiPy.title.title
|
||||||
:members:
|
:members:
|
||||||
:undoc-members:
|
:undoc-members:
|
||||||
:show-inheritance:
|
:show-inheritance:
|
||||||
```
|
```
|
||||||
### libWiiPy.title.tmd module
|
|
||||||
|
|
||||||
|
### libWiiPy.title.tmd module
|
||||||
```{eval-rst}
|
```{eval-rst}
|
||||||
.. automodule:: libWiiPy.title.tmd
|
.. automodule:: libWiiPy.title.tmd
|
||||||
:members:
|
:members:
|
||||||
:undoc-members:
|
:undoc-members:
|
||||||
:show-inheritance:
|
: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}
|
```{eval-rst}
|
||||||
.. automodule:: libWiiPy.title.wad
|
.. automodule:: libWiiPy.title.wad
|
||||||
:members:
|
:members:
|
||||||
:undoc-members:
|
:undoc-members:
|
||||||
:show-inheritance:
|
:show-inheritance:
|
||||||
```
|
```
|
||||||
## Module contents
|
|
||||||
|
|
||||||
|
## Module contents
|
||||||
```{eval-rst}
|
```{eval-rst}
|
||||||
.. automodule:: libWiiPy.title
|
.. automodule:: libWiiPy.title
|
||||||
:members:
|
:members:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "libWiiPy"
|
name = "libWiiPy"
|
||||||
version = "0.4.1"
|
version = "0.5.0"
|
||||||
authors = [
|
authors = [
|
||||||
{ name="NinjaCheetah", email="ninjacheetah@ncxprogramming.com" },
|
{ name="NinjaCheetah", email="ninjacheetah@ncxprogramming.com" },
|
||||||
{ name="Lillian Skinner", email="lillian@randommeaninglesscharacters.com" }
|
{ name="Lillian Skinner", email="lillian@randommeaninglesscharacters.com" }
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import os
|
|||||||
import pathlib
|
import pathlib
|
||||||
from dataclasses import dataclass as _dataclass
|
from dataclasses import dataclass as _dataclass
|
||||||
from typing import List
|
from typing import List
|
||||||
from ..shared import _align_value
|
from ..shared import _align_value, _pad_bytes
|
||||||
|
|
||||||
|
|
||||||
@_dataclass
|
@_dataclass
|
||||||
@@ -47,7 +47,10 @@ class U8Archive:
|
|||||||
self.u8_node_list: List[_U8Node] = [] # All the nodes in the header of a U8 file.
|
self.u8_node_list: List[_U8Node] = [] # All the nodes in the header of a U8 file.
|
||||||
self.file_name_list: List[str] = []
|
self.file_name_list: List[str] = []
|
||||||
self.file_data_list: List[bytes] = []
|
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:
|
def load(self, u8_data: bytes) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -64,26 +67,25 @@ class U8Archive:
|
|||||||
self.u8_magic = u8_data.read(4)
|
self.u8_magic = u8_data.read(4)
|
||||||
if self.u8_magic != b'\x55\xAA\x38\x2D':
|
if self.u8_magic != b'\x55\xAA\x38\x2D':
|
||||||
raise TypeError("This is not a valid U8 archive!")
|
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.
|
# 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.
|
# 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.
|
# 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)
|
self.data_offset = int.from_bytes(u8_data.read(4))
|
||||||
|
# Seek past 16 bytes of padding, then load the root node.
|
||||||
# Seek ahead to the size defined in the root node, because it's the total number of nodes in the file. The
|
u8_data.seek(u8_data.tell() + 16)
|
||||||
# rest of the data in the root node (not that it really matters) will get read when we read the whole list.
|
root_node_type = int.from_bytes(u8_data.read(1))
|
||||||
u8_data.seek(u8_data.tell() + 36)
|
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))
|
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.
|
# Seek back before the root node so that it gets read with all the rest.
|
||||||
u8_data.seek(u8_data.tell() - 12)
|
u8_data.seek(u8_data.tell() - 12)
|
||||||
# Iterate over the number of nodes that the root node lists.
|
# Iterate over the number of nodes that the root node lists.
|
||||||
for node in range(root_node_size):
|
for node in range(root_node_size):
|
||||||
node_type = 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(2))
|
node_name_offset = int.from_bytes(u8_data.read(3))
|
||||||
node_data_offset = int.from_bytes(u8_data.read(4))
|
node_data_offset = int.from_bytes(u8_data.read(4))
|
||||||
node_size = 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))
|
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.
|
# Add the number of bytes used for each file/folder name in the string table.
|
||||||
for file_name in self.file_name_list:
|
for file_name in self.file_name_list:
|
||||||
header_size += len(file_name) + 1
|
header_size += len(file_name) + 1
|
||||||
# The initial data offset is equal to the file header (32 bytes) + node data aligned to 16 bytes.
|
# 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, 16)
|
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?
|
# Adjust all nodes to place file data in the same order as the nodes. Why isn't it already like this?
|
||||||
current_data_offset = data_offset
|
current_data_offset = data_offset
|
||||||
|
current_name_offset = 0
|
||||||
for node in range(len(self.u8_node_list)):
|
for node in range(len(self.u8_node_list)):
|
||||||
if self.u8_node_list[node].type == 0:
|
if self.u8_node_list[node].type == 0:
|
||||||
self.u8_node_list[node].data_offset = current_data_offset
|
self.u8_node_list[node].data_offset = _align_value(current_data_offset, 32)
|
||||||
current_data_offset += self.u8_node_list[node].size
|
current_data_offset += _align_value(self.u8_node_list[node].size, 32)
|
||||||
# Begin joining all the U8 archive data into one variable.
|
# 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''
|
u8_data = b''
|
||||||
# Magic number.
|
# Magic number.
|
||||||
u8_data += b'\x55\xAA\x38\x2D'
|
u8_data += b'\x55\xAA\x38\x2D'
|
||||||
@@ -142,19 +148,18 @@ class U8Archive:
|
|||||||
u8_data += (b'\x00' * 16)
|
u8_data += (b'\x00' * 16)
|
||||||
# Iterate over all the U8 nodes and dump them.
|
# Iterate over all the U8 nodes and dump them.
|
||||||
for node in self.u8_node_list:
|
for node in self.u8_node_list:
|
||||||
u8_data += int.to_bytes(node.type, 2)
|
u8_data += int.to_bytes(node.type, 1)
|
||||||
u8_data += int.to_bytes(node.name_offset, 2)
|
u8_data += int.to_bytes(node.name_offset, 3)
|
||||||
u8_data += int.to_bytes(node.data_offset, 4)
|
u8_data += int.to_bytes(node.data_offset, 4)
|
||||||
u8_data += int.to_bytes(node.size, 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.
|
# 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:
|
for file_name in self.file_name_list:
|
||||||
u8_data += str.encode(file_name) + b'\x00'
|
u8_data += str.encode(file_name) + b'\x00'
|
||||||
# Apply the extra padding we calculated earlier by padding to where the data offset begins.
|
# Apply the extra padding we calculated earlier by padding to where the data offset begins.
|
||||||
while len(u8_data) < data_offset:
|
u8_data = _pad_bytes(u8_data, 64)
|
||||||
u8_data += b'\x00'
|
|
||||||
# Iterate all file data and dump it.
|
# Iterate all file data and dump it.
|
||||||
for file in self.file_data_list:
|
for file in self.file_data_list:
|
||||||
u8_data += file
|
u8_data += _pad_bytes(file, 32)
|
||||||
# Return the U8 archive.
|
# Return the U8 archive.
|
||||||
return u8_data
|
return u8_data
|
||||||
|
|
||||||
@@ -185,69 +190,58 @@ def extract_u8(u8_data, output_folder) -> None:
|
|||||||
u8_archive.load(u8_data)
|
u8_archive.load(u8_data)
|
||||||
# This variable stores the path of the directory we're currently processing.
|
# This variable stores the path of the directory we're currently processing.
|
||||||
current_dir = output_folder
|
current_dir = output_folder
|
||||||
# This variable stores the final nodes for every directory we've entered, and is used to handle the recursion of
|
# This variable stores the order of directory nodes leading to the current working directory, to make sure that
|
||||||
# those directories to ensure that everything gets where it belongs.
|
# things get where they belong.
|
||||||
directory_recursion = [0]
|
parent_dirs = [0]
|
||||||
# Iterate over every node and extract the files and folders.
|
|
||||||
for node in range(len(u8_archive.u8_node_list)):
|
for node in range(len(u8_archive.u8_node_list)):
|
||||||
# Code for a directory node. Second check just ensures we ignore the root node.
|
# Code for a directory node (excluding the root node since that already exists).
|
||||||
if u8_archive.u8_node_list[node].type == 256 and u8_archive.u8_node_list[node].name_offset != 0:
|
if u8_archive.u8_node_list[node].type == 1 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
|
if u8_archive.u8_node_list[node].data_offset == parent_dirs[-1]:
|
||||||
# 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]:
|
|
||||||
current_dir = current_dir.joinpath(u8_archive.file_name_list[node])
|
current_dir = current_dir.joinpath(u8_archive.file_name_list[node])
|
||||||
os.mkdir(current_dir)
|
current_dir.mkdir(exist_ok=True)
|
||||||
# If the current node is beyond the end of the current directory, we've followed that path all the way down,
|
parent_dirs.append(node)
|
||||||
# so reset back to the root directory and put our new directory there.
|
else:
|
||||||
elif node + 1 > directory_recursion[-1]:
|
# Go up until we're back at the correct level.
|
||||||
current_dir = output_folder.joinpath(u8_archive.file_name_list[node])
|
while u8_archive.u8_node_list[node].data_offset != parent_dirs[-1]:
|
||||||
os.mkdir(current_dir)
|
parent_dirs.pop()
|
||||||
# This check is here just in case a directory ever ends with an empty directory and not a file.
|
parent_dirs.append(node)
|
||||||
elif node + 1 == directory_recursion[-1]:
|
current_dir = output_folder
|
||||||
current_dir = current_dir.parent
|
# Rebuild current working directory, and make sure all directories in the path exist.
|
||||||
directory_recursion.pop()
|
for directory in parent_dirs:
|
||||||
# If the last node for the directory we just processed is new (which is always should be), add it to the
|
current_dir = current_dir.joinpath(u8_archive.file_name_list[directory])
|
||||||
# recursion array.
|
current_dir.mkdir(exist_ok=True)
|
||||||
if u8_archive.u8_node_list[node].size not in directory_recursion:
|
|
||||||
directory_recursion.append(u8_archive.u8_node_list[node].size)
|
|
||||||
# Code for a file node.
|
# Code for a file node.
|
||||||
elif u8_archive.u8_node_list[node].type == 0:
|
elif u8_archive.u8_node_list[node].type == 0:
|
||||||
# Write out the file to the current directory.
|
open(current_dir.joinpath(u8_archive.file_name_list[node]), "wb").write(u8_archive.file_data_list[node])
|
||||||
output_file = open(current_dir.joinpath(u8_archive.file_name_list[node]), "wb")
|
# Handle an invalid node type.
|
||||||
output_file.write(u8_archive.file_data_list[node])
|
elif u8_archive.u8_node_list[node].type != 0 and u8_archive.u8_node_list[node].type != 1:
|
||||||
output_file.close()
|
raise ValueError("A node with an invalid type (" + str(u8_archive.u8_node_list[node].type) + ") was found!")
|
||||||
# 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!")
|
|
||||||
|
|
||||||
|
|
||||||
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.
|
# First, get the list of everything in current path.
|
||||||
root_list = os.listdir(current_path)
|
root_list = os.listdir(current_path)
|
||||||
file_list = []
|
file_list = []
|
||||||
dir_list = []
|
dir_list = []
|
||||||
# Create separate lists of the files and directories in the current directory so that we can handle the files first.
|
# 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:
|
for path in root_list:
|
||||||
if os.path.isfile(current_path.joinpath(path)):
|
if os.path.isfile(current_path.joinpath(path)):
|
||||||
file_list.append(path)
|
file_list.append(path)
|
||||||
elif os.path.isdir(current_path.joinpath(path)):
|
elif os.path.isdir(current_path.joinpath(path)):
|
||||||
dir_list.append(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
|
# 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:
|
for file in file_list:
|
||||||
node_count += 1
|
node_count += 1
|
||||||
u8_archive.file_name_list.append(file)
|
u8_archive.file_name_list.append(file)
|
||||||
u8_archive.file_data_list.append(open(current_path.joinpath(file), "rb").read())
|
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])))
|
u8_archive.u8_node_list.append(_U8Node(0, -1, -1, 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.
|
|
||||||
# For directories, add their name to the file name list, add empty data to the file data list (since they obviously
|
# 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
|
# 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.
|
# 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_name_list.append(directory)
|
||||||
u8_archive.file_data_list.append(b'')
|
u8_archive.file_data_list.append(b'')
|
||||||
max_node = node_count + sum(1 for _ in current_path.joinpath(directory).rglob('*'))
|
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))
|
u8_archive.u8_node_list.append(_U8Node(1, -1, parent_node, 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 = _pack_u8_dir(u8_archive, current_path.joinpath(directory), node_count,
|
||||||
u8_archive, node_count, name_offset = _pack_u8_dir(u8_archive, current_path.joinpath(directory), node_count,
|
u8_archive.u8_node_list.index(u8_archive.u8_node_list[-1]))
|
||||||
name_offset)
|
|
||||||
# Return the U8Archive object, the current node we're on, and the current name offset.
|
# 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:
|
def pack_u8(input_path) -> bytes:
|
||||||
@@ -279,34 +272,19 @@ def pack_u8(input_path) -> bytes:
|
|||||||
The data for the packed U8 archive.
|
The data for the packed U8 archive.
|
||||||
"""
|
"""
|
||||||
input_path = pathlib.Path(input_path)
|
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()
|
# 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.
|
# 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 = U8Archive()
|
||||||
u8_archive.file_name_list.append("")
|
u8_archive.file_name_list.append("")
|
||||||
u8_archive.file_data_list.append(b'')
|
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
|
# 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
|
# 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.
|
# really only necessary for the directory recursion.
|
||||||
u8_archive, _, _ = _pack_u8_dir(u8_archive, input_path, node_count=1, name_offset=1)
|
u8_archive, _ = _pack_u8_dir(u8_archive, input_path, node_count=1, parent_node=0)
|
||||||
return u8_archive.dump()
|
return u8_archive.dump()
|
||||||
elif os.path.isfile(input_path):
|
elif input_path.is_file():
|
||||||
# Simple code to handle if a single file is provided as input. Not really sure *why* you'd do this, since the
|
raise ValueError("This does not appear to be a directory.")
|
||||||
# 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)))
|
|
||||||
return u8_archive.dump()
|
|
||||||
else:
|
else:
|
||||||
raise FileNotFoundError("Input file/directory: \"" + str(input_path) + "\" does not exist!")
|
raise FileNotFoundError("Input directory: \"" + str(input_path) + "\" does not exist!")
|
||||||
|
|||||||
@@ -47,3 +47,69 @@ def _pad_bytes(data, alignment=64) -> bytes:
|
|||||||
while (len(data) % alignment) != 0:
|
while (len(data) % alignment) != 0:
|
||||||
data += b'\x00'
|
data += b'\x00'
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _bitmask(x: int) -> int:
|
||||||
|
return 1 << x
|
||||||
|
|
||||||
|
|
||||||
|
_wii_menu_versions = {
|
||||||
|
"Prelaunch": [0, 1, 2],
|
||||||
|
"1.0J": 64,
|
||||||
|
"1.0U": 33,
|
||||||
|
"1.0E": 34,
|
||||||
|
"2.0J": 128,
|
||||||
|
"2.0U": 97,
|
||||||
|
"2.0E": 130,
|
||||||
|
"2.1E": 162,
|
||||||
|
"2.2J": 192,
|
||||||
|
"2.2U": 193,
|
||||||
|
"2.2E": 194,
|
||||||
|
"3.0J": 224,
|
||||||
|
"3.0U": 225,
|
||||||
|
"3.0E": 226,
|
||||||
|
"3.1J": 256,
|
||||||
|
"3.1U": 257,
|
||||||
|
"3.1E": 258,
|
||||||
|
"3.2J": 288,
|
||||||
|
"3.2U": 289,
|
||||||
|
"3.2E": 290,
|
||||||
|
"3.3J": 352,
|
||||||
|
"3.3U": 353,
|
||||||
|
"3.3E": 354,
|
||||||
|
"3.3K": 326,
|
||||||
|
"3.4J": 384,
|
||||||
|
"3.4U": 385,
|
||||||
|
"3.4E": 386,
|
||||||
|
"3.5K": 390,
|
||||||
|
"4.0J": 416,
|
||||||
|
"4.0U": 417,
|
||||||
|
"4.0E": 418,
|
||||||
|
"4.1J": 448,
|
||||||
|
"4.1U": 449,
|
||||||
|
"4.1E": 450,
|
||||||
|
"4.1K": 454,
|
||||||
|
"4.2J": 480,
|
||||||
|
"4.2U": 481,
|
||||||
|
"4.2E": 482,
|
||||||
|
"4.2K": 486,
|
||||||
|
"4.3J": 512,
|
||||||
|
"4.3U": 513,
|
||||||
|
"4.3E": 514,
|
||||||
|
"4.3K": 518,
|
||||||
|
"4.3U-Mini": 4609,
|
||||||
|
"4.3E-Mini": 4610
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_vwii_menu_versions = {
|
||||||
|
"vWii-1.0.0J": 512,
|
||||||
|
"vWii-1.0.0U": 513,
|
||||||
|
"vWii-1.0.0E": 514,
|
||||||
|
"vWii-4.0.0J": 544,
|
||||||
|
"vWii-4.0.0U": 545,
|
||||||
|
"vWii-4.0.0E": 546,
|
||||||
|
"vWii-5.2.0J": 608,
|
||||||
|
"vWii-5.2.0U": 609,
|
||||||
|
"vWii-5.2.0E": 610,
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,8 +3,12 @@
|
|||||||
|
|
||||||
from .content import *
|
from .content import *
|
||||||
from .crypto import *
|
from .crypto import *
|
||||||
|
from .emunand import *
|
||||||
|
from .iospatcher import *
|
||||||
from .nus import *
|
from .nus import *
|
||||||
|
from .sys import *
|
||||||
from .ticket import *
|
from .ticket import *
|
||||||
from .title import *
|
from .title import *
|
||||||
from .tmd import *
|
from .tmd import *
|
||||||
|
from .util import *
|
||||||
from .wad import *
|
from .wad import *
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ vwii_key = '30bfc76e7c19afbb23163330ced7c28d'
|
|||||||
|
|
||||||
def get_common_key(common_key_index) -> bytes:
|
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
|
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:
|
case 2:
|
||||||
common_key_bin = binascii.unhexlify(vwii_key)
|
common_key_bin = binascii.unhexlify(vwii_key)
|
||||||
case _:
|
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
|
return common_key_bin
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
#
|
#
|
||||||
# See https://wiibrew.org/wiki/Title for details about how titles are formatted
|
# See https://wiibrew.org/wiki/Title for details about how titles are formatted
|
||||||
|
|
||||||
|
import binascii
|
||||||
import io
|
import io
|
||||||
import hashlib
|
import hashlib
|
||||||
from typing import List
|
from typing import List
|
||||||
|
from dataclasses import dataclass as _dataclass
|
||||||
from ..types import _ContentRecord
|
from ..types import _ContentRecord
|
||||||
from ..shared import _pad_bytes, _align_value
|
from ..shared import _pad_bytes, _align_value
|
||||||
from .crypto import decrypt_content, encrypt_content
|
from .crypto import decrypt_content, encrypt_content
|
||||||
@@ -84,7 +86,7 @@ class ContentRegion:
|
|||||||
content_region_data = b''
|
content_region_data = b''
|
||||||
for content in self.content_list:
|
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 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)
|
content_region_data = _pad_bytes(content_region_data, 64)
|
||||||
# Calculate padding after this content before the next one.
|
# Calculate padding after this content before the next one.
|
||||||
padding_bytes = 0
|
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.
|
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
|
Parameters
|
||||||
----------
|
----------
|
||||||
index : int
|
index : int
|
||||||
@@ -117,7 +123,17 @@ class ContentRegion:
|
|||||||
bytes
|
bytes
|
||||||
The encrypted content listed in the content record.
|
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
|
return content_enc
|
||||||
|
|
||||||
def get_enc_content_by_cid(self, cid: int) -> bytes:
|
def get_enc_content_by_cid(self, cid: int) -> bytes:
|
||||||
@@ -127,23 +143,23 @@ class ContentRegion:
|
|||||||
Parameters
|
Parameters
|
||||||
----------
|
----------
|
||||||
cid : int
|
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
|
Returns
|
||||||
-------
|
-------
|
||||||
bytes
|
bytes
|
||||||
The encrypted content listed in the content record.
|
The encrypted content listed in the content record.
|
||||||
"""
|
"""
|
||||||
# Find the index of the requested Content ID.
|
# Get a list of the current Content IDs, so we can make sure the target one exists.
|
||||||
content_index = None
|
content_ids = []
|
||||||
for content in self.content_records:
|
for record in self.content_records:
|
||||||
if content.content_id == cid:
|
content_ids.append(record.content_id)
|
||||||
content_index = content.index
|
if cid not in content_ids:
|
||||||
# If finding a matching ID was unsuccessful, that means that no content with that ID is in the TMD, so
|
raise ValueError("You are trying to get a content with Content ID " + str(cid) + ", but no content with "
|
||||||
# return a Value Error.
|
"that ID exists!")
|
||||||
if content_index is None:
|
# Get the content index associated with the CID we now know exists.
|
||||||
raise ValueError("The Content ID requested does not exist in the TMD's content records.")
|
target_index = content_ids.index(cid)
|
||||||
# Call get_enc_content_by_index() using the index we just found.
|
content_index = self.content_records[target_index].index
|
||||||
content_enc = self.get_enc_content_by_index(content_index)
|
content_enc = self.get_enc_content_by_index(content_index)
|
||||||
return content_enc
|
return content_enc
|
||||||
|
|
||||||
@@ -158,68 +174,84 @@ class ContentRegion:
|
|||||||
"""
|
"""
|
||||||
return self.content_list
|
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.
|
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
|
Parameters
|
||||||
----------
|
----------
|
||||||
index : int
|
index : int
|
||||||
The index of the content you want to get.
|
The content index of the content you want to get.
|
||||||
title_key : bytes
|
title_key : bytes
|
||||||
The Title Key for the title the content is from.
|
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
|
Returns
|
||||||
-------
|
-------
|
||||||
bytes
|
bytes
|
||||||
The decrypted content listed in the content record.
|
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_enc = self.get_enc_content_by_index(index)
|
||||||
content_dec = decrypt_content(content_enc, title_key, self.content_records[index].index,
|
content_dec = decrypt_content(content_enc, title_key, index, self.content_records[target_index].content_size)
|
||||||
self.content_records[index].content_size)
|
|
||||||
# Hash the decrypted content and ensure that the hash matches the one in its Content Record.
|
# 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.
|
# 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_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.
|
# Compare the hash and throw a ValueError if the hash doesn't match.
|
||||||
if content_dec_hash != content_record_hash:
|
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 "
|
if skip_hash:
|
||||||
"have been used!\n"
|
print("Ignoring hash mismatch for content index " + str(index))
|
||||||
"Expected hash is: {}\n".format(content_record_hash) +
|
else:
|
||||||
"Actual hash is: {}".format(content_dec_hash))
|
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
|
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.
|
Gets an individual content from the content region based on the provided Content ID, in decrypted form.
|
||||||
|
|
||||||
Parameters
|
Parameters
|
||||||
----------
|
----------
|
||||||
cid : int
|
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
|
title_key : bytes
|
||||||
The Title Key for the title the content is from.
|
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
|
Returns
|
||||||
-------
|
-------
|
||||||
bytes
|
bytes
|
||||||
The decrypted content listed in the content record.
|
The decrypted content listed in the content record.
|
||||||
"""
|
"""
|
||||||
# Find the index of the requested Content ID.
|
# Get a list of the current Content IDs, so we can make sure the target one exists.
|
||||||
content_index = None
|
content_ids = []
|
||||||
for content in self.content_records:
|
for record in self.content_records:
|
||||||
if content.content_id == cid:
|
content_ids.append(record.content_id)
|
||||||
content_index = content.index
|
if cid not in content_ids:
|
||||||
# If finding a matching ID was unsuccessful, that means that no content with that ID is in the TMD, so
|
raise ValueError("You are trying to get a content with Content ID " + str(cid) + ", but no content with "
|
||||||
# return a Value Error.
|
"that ID exists!")
|
||||||
if content_index is None:
|
# Get the content index associated with the CID we now know exists.
|
||||||
raise ValueError("The Content ID requested does not exist in the TMD's content records.")
|
target_index = content_ids.index(cid)
|
||||||
# Call get_content_by_index() using the index we just found.
|
content_index = self.content_records[target_index].index
|
||||||
content_dec = self.get_content_by_index(content_index, title_key)
|
content_dec = self.get_content_by_index(content_index, title_key, skip_hash)
|
||||||
return content_dec
|
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.
|
Gets a list of all contents from the content region, in decrypted form.
|
||||||
|
|
||||||
@@ -227,6 +259,8 @@ class ContentRegion:
|
|||||||
----------
|
----------
|
||||||
title_key : bytes
|
title_key : bytes
|
||||||
The Title Key for the title the content is from.
|
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
|
Returns
|
||||||
-------
|
-------
|
||||||
@@ -236,19 +270,19 @@ class ContentRegion:
|
|||||||
dec_contents: List[bytes] = []
|
dec_contents: List[bytes] = []
|
||||||
# Iterate over every content, get the decrypted version of it, then add it to a list and return it.
|
# 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):
|
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
|
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:
|
content_hash: bytes) -> None:
|
||||||
"""
|
"""
|
||||||
Sets the provided index to a new content with the provided Content ID. Hashes and size of the content are
|
Adds a new encrypted content to the ContentRegion, and adds the provided Content ID, index, content type,
|
||||||
set in the content record, with a new record being added if necessary.
|
content size, and content hash to a new record in the ContentRecord list.
|
||||||
|
|
||||||
Parameters
|
Parameters
|
||||||
----------
|
----------
|
||||||
enc_content : bytes
|
enc_content : bytes
|
||||||
The new encrypted content to set.
|
The new encrypted content to add.
|
||||||
cid : int
|
cid : int
|
||||||
The Content ID to assign the new content in the content record.
|
The Content ID to assign the new content in the content record.
|
||||||
index : int
|
index : int
|
||||||
@@ -260,54 +294,120 @@ class ContentRegion:
|
|||||||
content_hash : bytes
|
content_hash : bytes
|
||||||
The hash of the new encrypted content when decrypted.
|
The hash of the new encrypted content when decrypted.
|
||||||
"""
|
"""
|
||||||
# Save the number of contents currently in the content region and records.
|
# Check to make sure this isn't reusing an already existing Content ID or index first.
|
||||||
num_contents = len(self.content_records)
|
for record in self.content_records:
|
||||||
# Check if a record already exists for this index. If it doesn't, create it.
|
if record.content_id == cid:
|
||||||
if (index + 1) > num_contents:
|
raise ValueError("Content with a Content ID of " + str(cid) + " already exists!")
|
||||||
# Ensure that you aren't attempting to create a gap before appending.
|
elif record.index == index:
|
||||||
if (index + 1) > num_contents + 1:
|
raise ValueError("Content with an index of " + str(index) + " already exists!")
|
||||||
raise ValueError("You are trying to set the content at position " + str(index) + ", but no content "
|
# If we're good, then append all the data and create a new ContentRecord().
|
||||||
"exists at position " + str(index - 1) + "!")
|
self.content_list.append(enc_content)
|
||||||
self.content_records.append(_ContentRecord(cid, index, content_type, content_size, content_hash))
|
self.content_records.append(_ContentRecord(cid, index, content_type, content_size, content_hash))
|
||||||
# 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:
|
|
||||||
self.content_list.append(enc_content)
|
|
||||||
else:
|
|
||||||
self.content_list[index] = enc_content
|
|
||||||
|
|
||||||
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
|
Adds a new decrypted content to the ContentRegion, and adds the provided Content ID, index, content type,
|
||||||
set in the content record, with a new record being added if necessary.
|
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
|
Parameters
|
||||||
----------
|
----------
|
||||||
dec_content : bytes
|
dec_content : bytes
|
||||||
The new decrypted content to set.
|
The new decrypted content to add.
|
||||||
cid : int
|
cid : int
|
||||||
The Content ID to assign the new content in the content record.
|
The Content ID to assign the new content in the content record.
|
||||||
index : int
|
index : int
|
||||||
The index to place the new content at.
|
The index to place the new content at.
|
||||||
content_type : int
|
content_type : int
|
||||||
The type of the new content.
|
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
|
title_key : bytes
|
||||||
The Title Key that matches the new decrypted content.
|
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.
|
# 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.
|
# 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.
|
# Encrypt the content using the provided Title Key and index.
|
||||||
enc_content = encrypt_content(dec_content, title_key, index)
|
enc_content = encrypt_content(dec_content, title_key, index)
|
||||||
# Pass values to set_enc_content()
|
# 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:
|
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
|
it matches the record at that index. Not recommended for most use cases, use decrypted content and
|
||||||
load_content() instead.
|
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
|
Parameters
|
||||||
----------
|
----------
|
||||||
enc_content : bytes
|
enc_content : bytes
|
||||||
@@ -322,18 +426,30 @@ class ContentRegion:
|
|||||||
index : int
|
index : int
|
||||||
The content index to load the content at.
|
The content index to load the content at.
|
||||||
"""
|
"""
|
||||||
if (index + 1) > len(self.content_records) or len(self.content_records) == 0:
|
# Get a list of the current content indices, so we can make sure the target one exists. Doing it this way
|
||||||
raise IndexError("No content records have been loaded, or that index is higher than the highest entry in "
|
# ensures we can find the target, even if the highest content index is greater than the highest literal index.
|
||||||
"the content records.")
|
current_indices = []
|
||||||
if (index + 1) > len(self.content_list):
|
for record in self.content_records:
|
||||||
self.content_list.append(enc_content)
|
current_indices.append(record.index)
|
||||||
else:
|
if index not in current_indices:
|
||||||
self.content_list[index] = enc_content
|
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:
|
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
|
Loads the provided decrypted content into the ContentRegion 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.
|
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
|
Parameters
|
||||||
----------
|
----------
|
||||||
@@ -344,19 +460,136 @@ class ContentRegion:
|
|||||||
title_key: bytes
|
title_key: bytes
|
||||||
The Title Key that matches the decrypted content.
|
The Title Key that matches the decrypted content.
|
||||||
"""
|
"""
|
||||||
# Make sure that content records exist and that the provided index exists in them.
|
# Get a list of the current content indices, so we can make sure the target one exists. Doing it this way
|
||||||
if (index + 1) > len(self.content_records) or len(self.content_records) == 0:
|
# ensures we can find the target, even if the highest content index is greater than the highest literal index.
|
||||||
raise IndexError("No content records have been loaded, or that index is higher than the highest entry in "
|
current_indices = []
|
||||||
"the content records.")
|
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.
|
# Check the hash of the content against the hash stored in the record to ensure it matches.
|
||||||
content_hash = hashlib.sha1(dec_content).hexdigest()
|
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"
|
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()) +
|
"Expected hash is: {}\n".format(self.content_records[index].content_hash.decode()) +
|
||||||
"Actual hash is: {}".format(content_hash))
|
"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.
|
# 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)
|
enc_content = encrypt_content(dec_content, title_key, index)
|
||||||
if (index + 1) > len(self.content_list):
|
self.content_list[target_index] = enc_content
|
||||||
self.content_list.append(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:
|
||||||
|
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:
|
else:
|
||||||
self.content_list[index] = enc_content
|
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
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from .commonkeys import get_common_key
|
|||||||
from Crypto.Cipher import AES as _AES
|
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
|
# Converts a Title ID in various formats into the format required to act as an IV. Private function used by other
|
||||||
# crypto functions.
|
# crypto functions.
|
||||||
title_key_iv = b''
|
title_key_iv = b''
|
||||||
@@ -17,7 +17,7 @@ def _convert_tid_to_iv(title_id: str) -> bytes:
|
|||||||
title_key_iv = binascii.unhexlify(title_id)
|
title_key_iv = binascii.unhexlify(title_id)
|
||||||
# This catches the format b'\x00\x00\x00\x01\x00\x00\x00\x02'
|
# This catches the format b'\x00\x00\x00\x01\x00\x00\x00\x02'
|
||||||
elif len(title_id) == 8:
|
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.
|
# If it isn't one of those lengths, it cannot possibly be valid, so reject it.
|
||||||
else:
|
else:
|
||||||
raise ValueError("Title ID is not valid!")
|
raise ValueError("Title ID is not valid!")
|
||||||
|
|||||||
161
src/libWiiPy/title/emunand.py
Normal file
161
src/libWiiPy/title/emunand.py
Normal 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))
|
||||||
252
src/libWiiPy/title/iospatcher.py
Normal file
252
src/libWiiPy/title/iospatcher.py
Normal 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
124
src/libWiiPy/title/sys.py
Normal 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")
|
||||||
@@ -9,6 +9,7 @@ import hashlib
|
|||||||
from dataclasses import dataclass as _dataclass
|
from dataclasses import dataclass as _dataclass
|
||||||
from .crypto import decrypt_title_key
|
from .crypto import decrypt_title_key
|
||||||
from typing import List
|
from typing import List
|
||||||
|
from .util import title_ver_standard_to_dec
|
||||||
|
|
||||||
|
|
||||||
@_dataclass
|
@_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.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.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: 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.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.title_version: int = 0 # Version of the ticket's associated title.
|
||||||
self.permitted_titles: bytes = b'' # Permitted titles mask
|
self.permitted_titles: bytes = b'' # Permitted titles mask
|
||||||
@@ -125,17 +125,12 @@ class Ticket:
|
|||||||
# Title ID.
|
# Title ID.
|
||||||
ticket_data.seek(0x1DC)
|
ticket_data.seek(0x1DC)
|
||||||
self.title_id = binascii.hexlify(ticket_data.read(8))
|
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.
|
# Unknown data 1.
|
||||||
ticket_data.seek(0x1E4)
|
ticket_data.seek(0x1E4)
|
||||||
self.unknown1 = ticket_data.read(2)
|
self.unknown1 = ticket_data.read(2)
|
||||||
# Title version.
|
# Title version.
|
||||||
ticket_data.seek(0x1E6)
|
ticket_data.seek(0x1E6)
|
||||||
title_version_high = int.from_bytes(ticket_data.read(1)) * 256
|
self.title_version = int.from_bytes(ticket_data.read(2))
|
||||||
ticket_data.seek(0x1E7)
|
|
||||||
title_version_low = int.from_bytes(ticket_data.read(1))
|
|
||||||
self.title_version = title_version_high + title_version_low
|
|
||||||
# Permitted titles mask.
|
# Permitted titles mask.
|
||||||
ticket_data.seek(0x1E8)
|
ticket_data.seek(0x1E8)
|
||||||
self.permitted_titles = ticket_data.read(4)
|
self.permitted_titles = ticket_data.read(4)
|
||||||
@@ -198,10 +193,7 @@ class Ticket:
|
|||||||
# Unknown data 1.
|
# Unknown data 1.
|
||||||
ticket_data += self.unknown1
|
ticket_data += self.unknown1
|
||||||
# Title version.
|
# Title version.
|
||||||
title_version_high = round(self.title_version / 256)
|
ticket_data += int.to_bytes(self.title_version, 2)
|
||||||
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)
|
|
||||||
# Permitted titles mask.
|
# Permitted titles mask.
|
||||||
ticket_data += self.permitted_titles
|
ticket_data += self.permitted_titles
|
||||||
# Permit mask.
|
# Permit mask.
|
||||||
@@ -260,6 +252,27 @@ class Ticket:
|
|||||||
except OverflowError:
|
except OverflowError:
|
||||||
raise Exception("An error occurred during fakesigning. Ticket could not be fakesigned!")
|
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:
|
def get_title_id(self) -> str:
|
||||||
"""
|
"""
|
||||||
Gets the Title ID of the ticket's associated title.
|
Gets the Title ID of the ticket's associated title.
|
||||||
@@ -283,7 +296,7 @@ class Ticket:
|
|||||||
|
|
||||||
See Also
|
See Also
|
||||||
--------
|
--------
|
||||||
commonkeys.get_common_key
|
libWiiPy.title.commonkeys.get_common_key
|
||||||
"""
|
"""
|
||||||
match self.common_key_index:
|
match self.common_key_index:
|
||||||
case 0:
|
case 0:
|
||||||
@@ -307,7 +320,8 @@ class Ticket:
|
|||||||
|
|
||||||
def set_title_id(self, title_id) -> None:
|
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
|
Parameters
|
||||||
----------
|
----------
|
||||||
@@ -316,5 +330,34 @@ class Ticket:
|
|||||||
"""
|
"""
|
||||||
if len(title_id) != 16:
|
if len(title_id) != 16:
|
||||||
raise ValueError("Invalid Title ID! Title IDs must be 8 bytes long.")
|
raise ValueError("Invalid Title ID! Title IDs must be 8 bytes long.")
|
||||||
self.title_id_str = title_id
|
self.title_id = title_id.encode()
|
||||||
self.title_id = binascii.unhexlify(title_id)
|
|
||||||
|
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.")
|
||||||
|
|||||||
@@ -3,10 +3,12 @@
|
|||||||
#
|
#
|
||||||
# See https://wiibrew.org/wiki/Title for details about how titles are formatted
|
# See https://wiibrew.org/wiki/Title for details about how titles are formatted
|
||||||
|
|
||||||
|
import math
|
||||||
from .content import ContentRegion
|
from .content import ContentRegion
|
||||||
from .ticket import Ticket
|
from .ticket import Ticket
|
||||||
from .tmd import TMD
|
from .tmd import TMD
|
||||||
from .wad import WAD
|
from .wad import WAD
|
||||||
|
from .crypto import encrypt_title_key
|
||||||
|
|
||||||
|
|
||||||
class Title:
|
class Title:
|
||||||
@@ -56,7 +58,7 @@ class Title:
|
|||||||
self.content.load(self.wad.get_content_data(), self.tmd.content_records)
|
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
|
# 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.
|
# 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 "
|
raise ValueError("The Title IDs of the TMD and Ticket in this WAD do not match. This WAD appears to be "
|
||||||
"invalid.")
|
"invalid.")
|
||||||
|
|
||||||
@@ -74,6 +76,7 @@ class Title:
|
|||||||
if self.tmd.title_id == "0000000100000001":
|
if self.tmd.title_id == "0000000100000001":
|
||||||
self.wad.wad_type = "ib"
|
self.wad.wad_type = "ib"
|
||||||
# Dump the TMD and set it in the WAD.
|
# 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())
|
self.wad.set_tmd_data(self.tmd.dump())
|
||||||
# Dump the Ticket and set it in the WAD.
|
# Dump the Ticket and set it in the WAD.
|
||||||
self.wad.set_ticket_data(self.ticket.dump())
|
self.wad.set_ticket_data(self.ticket.dump())
|
||||||
@@ -119,7 +122,8 @@ class Title:
|
|||||||
|
|
||||||
def set_title_id(self, title_id: str) -> None:
|
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
|
Parameters
|
||||||
----------
|
----------
|
||||||
@@ -129,9 +133,26 @@ class Title:
|
|||||||
if len(title_id) != 16:
|
if len(title_id) != 16:
|
||||||
raise ValueError("Invalid Title ID! Title IDs must be 8 bytes long.")
|
raise ValueError("Invalid Title ID! Title IDs must be 8 bytes long.")
|
||||||
self.tmd.set_title_id(title_id)
|
self.tmd.set_title_id(title_id)
|
||||||
|
title_key_decrypted = self.ticket.get_title_key()
|
||||||
self.ticket.set_title_id(title_id)
|
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.
|
Gets an individual content from the content region based on the provided index, in decrypted form.
|
||||||
|
|
||||||
@@ -139,19 +160,18 @@ class Title:
|
|||||||
----------
|
----------
|
||||||
index : int
|
index : int
|
||||||
The index of the content you want to get.
|
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
|
Returns
|
||||||
-------
|
-------
|
||||||
bytes
|
bytes
|
||||||
The decrypted content listed in the content record.
|
The decrypted content listed in the content record.
|
||||||
"""
|
"""
|
||||||
# Load the Title Key from the Ticket.
|
dec_content = self.content.get_content_by_index(index, self.ticket.get_title_key(), skip_hash)
|
||||||
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)
|
|
||||||
return dec_content
|
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.
|
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
|
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.
|
||||||
|
skip_hash : bool, optional
|
||||||
|
Skip the hash check and return the content regardless of its hash. Defaults to false.
|
||||||
|
|
||||||
Returns
|
Returns
|
||||||
-------
|
-------
|
||||||
bytes
|
bytes
|
||||||
The decrypted content listed in the content record.
|
The decrypted content listed in the content record.
|
||||||
"""
|
"""
|
||||||
# Load the Title Key from the Ticket.
|
dec_content = self.content.get_content_by_cid(cid, self.ticket.get_title_key(), skip_hash)
|
||||||
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)
|
|
||||||
return dec_content
|
return dec_content
|
||||||
|
|
||||||
def set_enc_content(self, enc_content: bytes, cid: int, index: int, content_type: int, content_size: int,
|
def get_title_size(self) -> 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
|
Gets the installed size of the title, including the TMD and Ticket, in bytes.
|
||||||
set in the content record, with a new record being added if necessary. The TMD is also updated to match the new
|
|
||||||
records.
|
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
|
Parameters
|
||||||
----------
|
----------
|
||||||
enc_content : bytes
|
enc_content : bytes
|
||||||
The new encrypted content to set.
|
The new encrypted content to set.
|
||||||
cid : int
|
|
||||||
The Content ID to assign the new content in the content record.
|
|
||||||
index : int
|
index : int
|
||||||
The index to place the new content at.
|
The index to place the new content at.
|
||||||
content_type : int
|
|
||||||
The type of the new content.
|
|
||||||
content_size : int
|
content_size : int
|
||||||
The size of the new encrypted content when decrypted.
|
The size of the new encrypted content when decrypted.
|
||||||
content_hash : bytes
|
content_hash : bytes
|
||||||
The hash of the new encrypted content when decrypted.
|
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.
|
# 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.
|
# Update the TMD to match.
|
||||||
self.tmd.content_records = self.content.content_records
|
self.tmd.content_records = self.content.content_records
|
||||||
|
|
||||||
def set_content(self, dec_content: bytes, 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
|
Sets the content at the provided content index to the provided new decrypted content. The hash and content size
|
||||||
set in the content record, with a new record being added if necessary. The Title Key is sourced from this
|
of this content will be generated and then set in the corresponding content record. A new Content ID or content
|
||||||
title's loaded ticket. The TMD is also updated to match the new records.
|
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
|
Parameters
|
||||||
----------
|
----------
|
||||||
dec_content : bytes
|
dec_content : bytes
|
||||||
The new decrypted content to set.
|
The new decrypted content to set.
|
||||||
cid : int
|
|
||||||
The Content ID to assign the new content in the content record.
|
|
||||||
index : int
|
index : int
|
||||||
The index to place the new content at.
|
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.
|
The type of the new content.
|
||||||
"""
|
"""
|
||||||
# Set the decrypted 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.
|
# Update the TMD to match.
|
||||||
self.tmd.content_records = self.content.content_records
|
self.tmd.content_records = self.content.content_records
|
||||||
|
|
||||||
def load_content(self, dec_content: bytes, index: int) -> None:
|
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
|
Loads the provided decrypted content into the ContentRegion 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.
|
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
|
Parameters
|
||||||
----------
|
----------
|
||||||
@@ -249,3 +316,22 @@ class Title:
|
|||||||
"""
|
"""
|
||||||
self.tmd.fakesign()
|
self.tmd.fakesign()
|
||||||
self.ticket.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
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ import binascii
|
|||||||
import hashlib
|
import hashlib
|
||||||
import struct
|
import struct
|
||||||
from typing import List
|
from typing import List
|
||||||
|
from enum import IntEnum
|
||||||
from ..types import _ContentRecord
|
from ..types import _ContentRecord
|
||||||
|
from ..shared import _bitmask
|
||||||
|
from .util import title_ver_dec_to_standard, title_ver_standard_to_dec
|
||||||
|
|
||||||
|
|
||||||
class TMD:
|
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_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.ios_version: int = 0 # The IOS version the associated title runs on.
|
||||||
self.title_id: str = "" # The Title ID of the associated title.
|
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.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.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.ratings: bytes = b'' # The parental controls rating of the associated title.
|
||||||
self.reserved1: bytes = b'' # Unknown data labeled "Reserved" on WiiBrew.
|
self.reserved1: bytes = b'' # Unknown data labeled "Reserved" on WiiBrew.
|
||||||
self.ipc_mask: bytes = b''
|
self.ipc_mask: bytes = b''
|
||||||
self.reserved2: bytes = b'' # Other "Reserved" data from WiiBrew.
|
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: int = 0 # The version of the associated title.
|
||||||
self.title_version_converted: int = 0 # The title version in vX.X format.
|
self.title_version_converted: int = 0 # The title version in vX.X format.
|
||||||
self.num_contents: int = 0 # The number of contents contained in the associated title.
|
self.num_contents: int = 0 # The number of contents contained in the associated title.
|
||||||
@@ -108,7 +111,7 @@ class TMD:
|
|||||||
tmd_data.seek(0x194)
|
tmd_data.seek(0x194)
|
||||||
content_type_bin = tmd_data.read(4)
|
content_type_bin = tmd_data.read(4)
|
||||||
content_type_hex = binascii.hexlify(content_type_bin)
|
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.
|
# Publisher of the title.
|
||||||
tmd_data.seek(0x198)
|
tmd_data.seek(0x198)
|
||||||
self.group_id = int.from_bytes(tmd_data.read(2))
|
self.group_id = int.from_bytes(tmd_data.read(2))
|
||||||
@@ -128,17 +131,14 @@ class TMD:
|
|||||||
# "Reserved" data 2.
|
# "Reserved" data 2.
|
||||||
tmd_data.seek(0x1C6)
|
tmd_data.seek(0x1C6)
|
||||||
self.reserved2 = tmd_data.read(18)
|
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)
|
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.
|
# Version number straight from the TMD.
|
||||||
tmd_data.seek(0x1DC)
|
tmd_data.seek(0x1DC)
|
||||||
self.title_version = int.from_bytes(tmd_data.read(2))
|
self.title_version = int.from_bytes(tmd_data.read(2))
|
||||||
# Calculate the converted version number by multiplying 0x1DC by 256 and adding 0x1DD.
|
# Calculate the converted version number via util module.
|
||||||
tmd_data.seek(0x1DC)
|
self.title_version_converted = title_ver_dec_to_standard(self.title_version, self.title_id, bool(self.vwii))
|
||||||
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
|
|
||||||
# The number of contents listed in the TMD.
|
# The number of contents listed in the TMD.
|
||||||
tmd_data.seek(0x1DE)
|
tmd_data.seek(0x1DE)
|
||||||
self.num_contents = int.from_bytes(tmd_data.read(2))
|
self.num_contents = int.from_bytes(tmd_data.read(2))
|
||||||
@@ -189,7 +189,7 @@ class TMD:
|
|||||||
# Title's Title ID.
|
# Title's Title ID.
|
||||||
tmd_data += binascii.unhexlify(self.title_id)
|
tmd_data += binascii.unhexlify(self.title_id)
|
||||||
# Content type.
|
# Content type.
|
||||||
tmd_data += binascii.unhexlify(self.content_type)
|
tmd_data += binascii.unhexlify(self.title_type)
|
||||||
# Group ID.
|
# Group ID.
|
||||||
tmd_data += int.to_bytes(self.group_id, 2)
|
tmd_data += int.to_bytes(self.group_id, 2)
|
||||||
# 2 bytes of zero for reasons.
|
# 2 bytes of zero for reasons.
|
||||||
@@ -205,7 +205,7 @@ class TMD:
|
|||||||
# "Reserved" 2.
|
# "Reserved" 2.
|
||||||
tmd_data += self.reserved2
|
tmd_data += self.reserved2
|
||||||
# Access rights.
|
# Access rights.
|
||||||
tmd_data += self.access_rights
|
tmd_data += int.to_bytes(self.access_rights, 4)
|
||||||
# Title version.
|
# Title version.
|
||||||
tmd_data += int.to_bytes(self.title_version, 2)
|
tmd_data += int.to_bytes(self.title_version, 2)
|
||||||
# Number of contents.
|
# Number of contents.
|
||||||
@@ -257,12 +257,33 @@ class TMD:
|
|||||||
except OverflowError:
|
except OverflowError:
|
||||||
raise Exception("An error occurred during fakesigning. TMD could not be fakesigned!")
|
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:
|
def get_title_region(self) -> str:
|
||||||
"""
|
"""
|
||||||
Gets the region of the TMD's associated title.
|
Gets the region of the TMD's associated title.
|
||||||
|
|
||||||
Can be one of several possible values:
|
Can be one of several possible values:
|
||||||
'JAP', 'USA', 'EUR', 'WORLD', or 'KOR'.
|
'Japan', 'North America', 'Europe', 'World', or 'Korea'.
|
||||||
|
|
||||||
Returns
|
Returns
|
||||||
-------
|
-------
|
||||||
@@ -271,29 +292,15 @@ class TMD:
|
|||||||
"""
|
"""
|
||||||
match self.region:
|
match self.region:
|
||||||
case 0:
|
case 0:
|
||||||
return "JAP"
|
return "Japan"
|
||||||
case 1:
|
case 1:
|
||||||
return "USA"
|
return "North America"
|
||||||
case 2:
|
case 2:
|
||||||
return "EUR"
|
return "Europe"
|
||||||
case 3:
|
case 3:
|
||||||
return "WORLD"
|
return "World"
|
||||||
case 4:
|
case 4:
|
||||||
return "KOR"
|
return "Korea"
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
def get_title_type(self) -> str:
|
def get_title_type(self) -> str:
|
||||||
"""
|
"""
|
||||||
@@ -307,8 +314,7 @@ class TMD:
|
|||||||
str
|
str
|
||||||
The type of the title.
|
The type of the title.
|
||||||
"""
|
"""
|
||||||
title_id_high = self.title_id[:8]
|
match self.title_type:
|
||||||
match title_id_high:
|
|
||||||
case '00000001':
|
case '00000001':
|
||||||
return "System"
|
return "System"
|
||||||
case '00010000':
|
case '00010000':
|
||||||
@@ -326,28 +332,40 @@ class TMD:
|
|||||||
case _:
|
case _:
|
||||||
return "Unknown"
|
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.
|
Gets the type of content contained in the TMD's associated title.
|
||||||
|
|
||||||
Can be one of several possible values:
|
Can be one of several possible values:
|
||||||
'Normal', 'Development/Unknown', 'Hash Tree', 'DLC', or 'Shared'
|
'Normal', 'Development/Unknown', 'Hash Tree', 'DLC', or 'Shared'
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
content_index : int
|
||||||
|
The index of the content you want the type of.
|
||||||
|
|
||||||
Returns
|
Returns
|
||||||
-------
|
-------
|
||||||
str
|
str
|
||||||
The type of content.
|
The type of content.
|
||||||
"""
|
"""
|
||||||
match self.content_type:
|
# Get a list of the current content indices, so we can make sure the target one exists. Doing it this way
|
||||||
case '00000001':
|
# 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"
|
return "Normal"
|
||||||
case '00000002':
|
case 2:
|
||||||
return "Development/Unknown"
|
return "Development/Unknown"
|
||||||
case '00000003':
|
case 3:
|
||||||
return "Hash Tree"
|
return "Hash Tree"
|
||||||
case '00004001':
|
case 16385:
|
||||||
return "DLC"
|
return "DLC"
|
||||||
case '00008001':
|
case 32769:
|
||||||
return "Shared"
|
return "Shared"
|
||||||
case _:
|
case _:
|
||||||
return "Unknown"
|
return "Unknown"
|
||||||
@@ -372,9 +390,31 @@ class TMD:
|
|||||||
raise IndexError("Invalid content record! TMD lists '" + str(self.num_contents - 1) +
|
raise IndexError("Invalid content record! TMD lists '" + str(self.num_contents - 1) +
|
||||||
"' contents but index was '" + str(record) + "'!")
|
"' 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:
|
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
|
Parameters
|
||||||
----------
|
----------
|
||||||
@@ -384,3 +424,37 @@ class TMD:
|
|||||||
if len(title_id) != 16:
|
if len(title_id) != 16:
|
||||||
raise ValueError("Invalid Title ID! Title IDs must be 8 bytes long.")
|
raise ValueError("Invalid Title ID! Title IDs must be 8 bytes long.")
|
||||||
self.title_id = title_id
|
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.")
|
||||||
|
|||||||
80
src/libWiiPy/title/util.py
Normal file
80
src/libWiiPy/title/util.py
Normal 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
|
||||||
@@ -103,13 +103,13 @@ class WAD:
|
|||||||
# Calculate file offsets from sizes. Every section of the WAD is padded out to a multiple of 0x40.
|
# 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
|
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_crl_offset = _align_value(wad_cert_offset + self.wad_cert_size)
|
||||||
wad_tik_offset = _align_value(wad_crl_offset + self.wad_crl_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_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.
|
# 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_meta_offset = _align_value(wad_content_offset + self.wad_content_size)
|
||||||
wad_content_offset = _align_value(wad_meta_offset + self.wad_meta_size)
|
|
||||||
# ====================================================================================
|
# ====================================================================================
|
||||||
# Load data for each WAD section based on the previously calculated offsets.
|
# 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.
|
# Retrieve the TMD data and write it out.
|
||||||
wad_data += self.get_tmd_data()
|
wad_data += self.get_tmd_data()
|
||||||
wad_data = _pad_bytes(wad_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.
|
# Retrieve the content data and write it out.
|
||||||
wad_data += self.get_content_data()
|
wad_data += self.get_content_data()
|
||||||
wad_data = _pad_bytes(wad_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
|
return wad_data
|
||||||
|
|
||||||
def get_wad_type(self) -> str:
|
def get_wad_type(self) -> str:
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from test_commonkeys import TestCommonKeys
|
from .title.commonkeys_test import *
|
||||||
|
from .title.nus_test import *
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
0
test/title/__init__.py
Normal file
0
test/title/__init__.py
Normal 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
|
# https://github.com/NinjaCheetah/libWiiPy
|
||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
67
test/title/nus_test.py
Normal file
67
test/title/nus_test.py
Normal 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()
|
||||||
Reference in New Issue
Block a user