mirror of
https://github.com/NinjaCheetah/libWiiPy.git
synced 2026-03-05 08:35:28 -05:00
Compare commits
40 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
|
|||
|
4f96e1b0d9
|
|||
|
bcd61b8a37
|
|||
|
a56fa6e051
|
|||
|
535de7f228
|
|||
|
adac67b158
|
20
README.md
20
README.md
@@ -1,22 +1,28 @@
|
||||

|
||||
# libWiiPy
|
||||
libWiiPy is a modern Python 3 library for handling the various files and formats found on the Wii. It aims to be simple to use, well maintained, and offer as many features as reasonably possible in one library, so that a newly-written Python program could reasonably do 100% of its Wii-related work with just one library. It also aims to be fully cross-platform, so that any tools written with it can also be cross-platform.
|
||||
libWiiPy is a modern Python 3 library for handling the various files and formats found on the Wii. It aims to be simple to use, well maintained, and offer as many features as reasonably possible in one library, so that a newly-written Python program could do 100% of its Wii-related work with just one library. It also aims to be fully cross-platform, so that any tools written with it can also be cross-platform.
|
||||
|
||||
libWiiPy is inspired by [libWiiSharp](https://github.com/TheShadowEevee/libWiiSharp), which was originally created by `Leathl` and is now maintained by [@TheShadowEevee](https://github.com/TheShadowEevee). If you're looking for a Wii library that isn't in Python, then go check it out!
|
||||
|
||||
|
||||
# Features
|
||||
This list will expand as libWiiPy is developed, but these features are currently available:
|
||||
- TMD and Ticket parsing (`.tmd`, `.tik`)
|
||||
- Title content decryption, re-encryption
|
||||
- Packing and unpacking WAD files (`.wad`)
|
||||
- TMD and Ticket parsing/editing (`.tmd`, `.tik`)
|
||||
- Title parsing/editing, including content encryption/decryption
|
||||
- WAD file parsing/editing (`.wad`)
|
||||
- Downloading titles from the NUS
|
||||
- Packing and unpacking U8 archives (`.app`, `.arc`)
|
||||
- Decompressing ASH files (`.ash`, both the standard variants and the variants found in My Pokémon Ranch)
|
||||
- IOS patching
|
||||
- NAND-related functionality:
|
||||
- EmuNAND title management (currently requires an existing EmuNAND)
|
||||
- `content.map` parsing/editing
|
||||
- `uid.sys` parsing/editing
|
||||
- Assorted miscellaneous features used to make the other core features possible
|
||||
|
||||
For a more detailed look at what's available in libWiiPy, check out our [API docs](https://ninjacheetah.github.io/libWiiPy).
|
||||
|
||||
# Usage
|
||||
A wiki, and in the future a potential documenation site, is being worked on, and can be accessed [here](https://github.com/NinjaCheetah/libWiiPy/wiki). It is currently fairly barebones, but it will be improved in the future.
|
||||
|
||||
The easiest way to get libWiiPy for your project is to install the latest version of the library from PyPI, as shown below.
|
||||
```sh
|
||||
pip install -U libWiiPy
|
||||
@@ -29,6 +35,8 @@ pip install -U git+https://github.com/NinjaCheetah/libWiiPy
|
||||
```
|
||||
Please be aware that because libWiiPy is in a very early state right now, many features may be subject to change, and methods and properties available now have the potential to disappear in the future.
|
||||
|
||||
For more tips on getting started, see our guide [here](https://ninjacheetah.github.io/libWiiPy/usage/installation.html).
|
||||
|
||||
# Building
|
||||
To build this package locally, the steps are quite simple, and should apply to all platforms. Make sure you've set up your `venv` first!
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
## Submodules
|
||||
|
||||
### libWiiPy.title.commonkeys module
|
||||
|
||||
```{eval-rst}
|
||||
.. automodule:: libWiiPy.title.commonkeys
|
||||
:members:
|
||||
@@ -12,7 +11,6 @@
|
||||
```
|
||||
|
||||
### libWiiPy.title.content module
|
||||
|
||||
```{eval-rst}
|
||||
.. automodule:: libWiiPy.title.content
|
||||
:members:
|
||||
@@ -21,55 +19,86 @@
|
||||
```
|
||||
|
||||
### libWiiPy.title.crypto module
|
||||
|
||||
```{eval-rst}
|
||||
.. automodule:: libWiiPy.title.crypto
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
```
|
||||
### libWiiPy.title.nus module
|
||||
|
||||
### libWiiPy.title.emunand module
|
||||
```{eval-rst}
|
||||
.. automodule:: libWiiPy.title.emunand
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
```
|
||||
|
||||
### libWiipy.title.iospatcher module
|
||||
```{eval-rst}
|
||||
.. automodule:: libWiiPy.title.iospatcher
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
```
|
||||
|
||||
### libWiiPy.title.nus module
|
||||
```{eval-rst}
|
||||
.. automodule:: libWiiPy.title.nus
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
```
|
||||
### libWiiPy.title.ticket module
|
||||
|
||||
### libWiiPy.title.sys module
|
||||
```{eval-rst}
|
||||
.. automodule:: libWiiPy.title.sys
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
```
|
||||
|
||||
### libWiiPy.title.ticket module
|
||||
```{eval-rst}
|
||||
.. automodule:: libWiiPy.title.ticket
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
```
|
||||
### libWiiPy.title.title module
|
||||
|
||||
### libWiiPy.title.title module
|
||||
```{eval-rst}
|
||||
.. automodule:: libWiiPy.title.title
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
```
|
||||
### libWiiPy.title.tmd module
|
||||
|
||||
### libWiiPy.title.tmd module
|
||||
```{eval-rst}
|
||||
.. automodule:: libWiiPy.title.tmd
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
```
|
||||
### libWiiPy.title.wad module
|
||||
|
||||
### libWiiPy.title.util module
|
||||
```{eval-rst}
|
||||
.. automodule:: libWiiPy.title.util
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
```
|
||||
|
||||
### libWiiPy.title.wad module
|
||||
```{eval-rst}
|
||||
.. automodule:: libWiiPy.title.wad
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
```
|
||||
## Module contents
|
||||
|
||||
## Module contents
|
||||
```{eval-rst}
|
||||
.. automodule:: libWiiPy.title
|
||||
:members:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "libWiiPy"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
authors = [
|
||||
{ name="NinjaCheetah", email="ninjacheetah@ncxprogramming.com" },
|
||||
{ name="Lillian Skinner", email="lillian@randommeaninglesscharacters.com" }
|
||||
@@ -9,17 +9,28 @@ description = "A modern Python library for handling files used by the Wii"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
classifiers = [
|
||||
"Programming Language :: Python :: 3",
|
||||
# How mature is this project? Common values are
|
||||
# 3 - Alpha
|
||||
# 4 - Beta
|
||||
# 5 - Production/Stable
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
]
|
||||
dependencies = [
|
||||
"pycryptodome",
|
||||
"requests"
|
||||
]
|
||||
keywords = ["Wii", "wii"]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/NinjaCheetah/libWiiPy"
|
||||
Documentation = "https://ninjacheetah.github.io/libWiiPy/"
|
||||
Repository = "https://github.com/NinjaCheetah/libWiiPy.git"
|
||||
Issues = "https://github.com/NinjaCheetah/libWiiPy/issues"
|
||||
|
||||
[build-system]
|
||||
|
||||
@@ -8,7 +8,7 @@ import os
|
||||
import pathlib
|
||||
from dataclasses import dataclass as _dataclass
|
||||
from typing import List
|
||||
from ..shared import _align_value
|
||||
from ..shared import _align_value, _pad_bytes
|
||||
|
||||
|
||||
@_dataclass
|
||||
@@ -47,7 +47,10 @@ class U8Archive:
|
||||
self.u8_node_list: List[_U8Node] = [] # All the nodes in the header of a U8 file.
|
||||
self.file_name_list: List[str] = []
|
||||
self.file_data_list: List[bytes] = []
|
||||
self.u8_file_structure = dict
|
||||
self.root_node_offset: int = 0
|
||||
self.header_size: int = 0
|
||||
self.data_offset: int = 0
|
||||
self.root_node: _U8Node = _U8Node(0, 0, 0, 0)
|
||||
|
||||
def load(self, u8_data: bytes) -> None:
|
||||
"""
|
||||
@@ -64,26 +67,25 @@ class U8Archive:
|
||||
self.u8_magic = u8_data.read(4)
|
||||
if self.u8_magic != b'\x55\xAA\x38\x2D':
|
||||
raise TypeError("This is not a valid U8 archive!")
|
||||
# The following code is all skipped because these values really don't matter for extraction. They honestly
|
||||
# really only matter to my code when they get calculated and used for packing.
|
||||
|
||||
# Offset of the root node, which will always be 0x20.
|
||||
# root_node_offset = int(binascii.hexlify(u8_data.read(4)), 16)
|
||||
self.root_node_offset = int.from_bytes(u8_data.read(4))
|
||||
# The size of the U8 header.
|
||||
# header_size = int(binascii.hexlify(u8_data.read(4)), 16)
|
||||
self.header_size = int.from_bytes(u8_data.read(4))
|
||||
# The offset of the data, which is root_node_offset + header_size, aligned to 0x10.
|
||||
# data_offset = int(binascii.hexlify(u8_data.read(4)), 16)
|
||||
|
||||
# Seek ahead to the size defined in the root node, because it's the total number of nodes in the file. The
|
||||
# rest of the data in the root node (not that it really matters) will get read when we read the whole list.
|
||||
u8_data.seek(u8_data.tell() + 36)
|
||||
self.data_offset = int.from_bytes(u8_data.read(4))
|
||||
# Seek past 16 bytes of padding, then load the root node.
|
||||
u8_data.seek(u8_data.tell() + 16)
|
||||
root_node_type = int.from_bytes(u8_data.read(1))
|
||||
root_node_name_offset = int.from_bytes(u8_data.read(3))
|
||||
root_node_data_offset = int.from_bytes(u8_data.read(4))
|
||||
root_node_size = int.from_bytes(u8_data.read(4))
|
||||
self.root_node = _U8Node(root_node_type, root_node_name_offset, root_node_data_offset, root_node_size)
|
||||
# Seek back before the root node so that it gets read with all the rest.
|
||||
u8_data.seek(u8_data.tell() - 12)
|
||||
# Iterate over the number of nodes that the root node lists.
|
||||
for node in range(root_node_size):
|
||||
node_type = int.from_bytes(u8_data.read(2))
|
||||
node_name_offset = int.from_bytes(u8_data.read(2))
|
||||
node_type = int.from_bytes(u8_data.read(1))
|
||||
node_name_offset = int.from_bytes(u8_data.read(3))
|
||||
node_data_offset = int.from_bytes(u8_data.read(4))
|
||||
node_size = int.from_bytes(u8_data.read(4))
|
||||
self.u8_node_list.append(_U8Node(node_type, node_name_offset, node_data_offset, node_size))
|
||||
@@ -120,15 +122,19 @@ class U8Archive:
|
||||
# Add the number of bytes used for each file/folder name in the string table.
|
||||
for file_name in self.file_name_list:
|
||||
header_size += len(file_name) + 1
|
||||
# The initial data offset is equal to the file header (32 bytes) + node data aligned to 16 bytes.
|
||||
data_offset = _align_value(header_size + 32, 16)
|
||||
# The initial data offset is equal to the file header (32 bytes) + node data aligned to 64 bytes.
|
||||
data_offset = _align_value(header_size + 32, 64)
|
||||
# Adjust all nodes to place file data in the same order as the nodes. Why isn't it already like this?
|
||||
current_data_offset = data_offset
|
||||
current_name_offset = 0
|
||||
for node in range(len(self.u8_node_list)):
|
||||
if self.u8_node_list[node].type == 0:
|
||||
self.u8_node_list[node].data_offset = current_data_offset
|
||||
current_data_offset += self.u8_node_list[node].size
|
||||
# Begin joining all the U8 archive data into one variable.
|
||||
self.u8_node_list[node].data_offset = _align_value(current_data_offset, 32)
|
||||
current_data_offset += _align_value(self.u8_node_list[node].size, 32)
|
||||
# Calculate the name offsets, including the extra 1 for the NULL byte at the end of each name.
|
||||
self.u8_node_list[node].name_offset = current_name_offset
|
||||
current_name_offset += len(self.file_name_list[node]) + 1
|
||||
# Begin joining all the U8 archive data into bytes.
|
||||
u8_data = b''
|
||||
# Magic number.
|
||||
u8_data += b'\x55\xAA\x38\x2D'
|
||||
@@ -142,19 +148,18 @@ class U8Archive:
|
||||
u8_data += (b'\x00' * 16)
|
||||
# Iterate over all the U8 nodes and dump them.
|
||||
for node in self.u8_node_list:
|
||||
u8_data += int.to_bytes(node.type, 2)
|
||||
u8_data += int.to_bytes(node.name_offset, 2)
|
||||
u8_data += int.to_bytes(node.type, 1)
|
||||
u8_data += int.to_bytes(node.name_offset, 3)
|
||||
u8_data += int.to_bytes(node.data_offset, 4)
|
||||
u8_data += int.to_bytes(node.size, 4)
|
||||
# Iterate over all file names and dump them. All file names are suffixed by a \x00 byte.
|
||||
for file_name in self.file_name_list:
|
||||
u8_data += str.encode(file_name) + b'\x00'
|
||||
# Apply the extra padding we calculated earlier by padding to where the data offset begins.
|
||||
while len(u8_data) < data_offset:
|
||||
u8_data += b'\x00'
|
||||
u8_data = _pad_bytes(u8_data, 64)
|
||||
# Iterate all file data and dump it.
|
||||
for file in self.file_data_list:
|
||||
u8_data += file
|
||||
u8_data += _pad_bytes(file, 32)
|
||||
# Return the U8 archive.
|
||||
return u8_data
|
||||
|
||||
@@ -185,69 +190,58 @@ def extract_u8(u8_data, output_folder) -> None:
|
||||
u8_archive.load(u8_data)
|
||||
# This variable stores the path of the directory we're currently processing.
|
||||
current_dir = output_folder
|
||||
# This variable stores the final nodes for every directory we've entered, and is used to handle the recursion of
|
||||
# those directories to ensure that everything gets where it belongs.
|
||||
directory_recursion = [0]
|
||||
# Iterate over every node and extract the files and folders.
|
||||
# This variable stores the order of directory nodes leading to the current working directory, to make sure that
|
||||
# things get where they belong.
|
||||
parent_dirs = [0]
|
||||
for node in range(len(u8_archive.u8_node_list)):
|
||||
# Code for a directory node. Second check just ensures we ignore the root node.
|
||||
if u8_archive.u8_node_list[node].type == 256 and u8_archive.u8_node_list[node].name_offset != 0:
|
||||
# The size value for a directory node is the position of the last node in this directory, with the root node
|
||||
# counting as node 1.
|
||||
# If the current node is below the end of the current directory, create this directory inside the previous
|
||||
# current directory and make the current.
|
||||
if node + 1 < directory_recursion[-1]:
|
||||
# Code for a directory node (excluding the root node since that already exists).
|
||||
if u8_archive.u8_node_list[node].type == 1 and u8_archive.u8_node_list[node].name_offset != 0:
|
||||
if u8_archive.u8_node_list[node].data_offset == parent_dirs[-1]:
|
||||
current_dir = current_dir.joinpath(u8_archive.file_name_list[node])
|
||||
os.mkdir(current_dir)
|
||||
# If the current node is beyond the end of the current directory, we've followed that path all the way down,
|
||||
# so reset back to the root directory and put our new directory there.
|
||||
elif node + 1 > directory_recursion[-1]:
|
||||
current_dir = output_folder.joinpath(u8_archive.file_name_list[node])
|
||||
os.mkdir(current_dir)
|
||||
# This check is here just in case a directory ever ends with an empty directory and not a file.
|
||||
elif node + 1 == directory_recursion[-1]:
|
||||
current_dir = current_dir.parent
|
||||
directory_recursion.pop()
|
||||
# If the last node for the directory we just processed is new (which is always should be), add it to the
|
||||
# recursion array.
|
||||
if u8_archive.u8_node_list[node].size not in directory_recursion:
|
||||
directory_recursion.append(u8_archive.u8_node_list[node].size)
|
||||
current_dir.mkdir(exist_ok=True)
|
||||
parent_dirs.append(node)
|
||||
else:
|
||||
# Go up until we're back at the correct level.
|
||||
while u8_archive.u8_node_list[node].data_offset != parent_dirs[-1]:
|
||||
parent_dirs.pop()
|
||||
parent_dirs.append(node)
|
||||
current_dir = output_folder
|
||||
# Rebuild current working directory, and make sure all directories in the path exist.
|
||||
for directory in parent_dirs:
|
||||
current_dir = current_dir.joinpath(u8_archive.file_name_list[directory])
|
||||
current_dir.mkdir(exist_ok=True)
|
||||
# Code for a file node.
|
||||
elif u8_archive.u8_node_list[node].type == 0:
|
||||
# Write out the file to the current directory.
|
||||
output_file = open(current_dir.joinpath(u8_archive.file_name_list[node]), "wb")
|
||||
output_file.write(u8_archive.file_data_list[node])
|
||||
output_file.close()
|
||||
# If this file is the final node for the current directory, pop() the recursion array and set the current
|
||||
# directory to the parent of the previous current.
|
||||
if node + 1 in directory_recursion:
|
||||
current_dir = current_dir.parent
|
||||
directory_recursion.pop()
|
||||
# Code for a totally unrecognized node type, which should not happen.
|
||||
elif u8_archive.u8_node_list[node].type != 0 and u8_archive.u8_node_list[node].type != 256:
|
||||
raise ValueError("A node with an invalid type (" + str(u8_archive.u8_node_list[node].type) + ") was"
|
||||
"found!")
|
||||
open(current_dir.joinpath(u8_archive.file_name_list[node]), "wb").write(u8_archive.file_data_list[node])
|
||||
# Handle an invalid node type.
|
||||
elif u8_archive.u8_node_list[node].type != 0 and u8_archive.u8_node_list[node].type != 1:
|
||||
raise ValueError("A node with an invalid type (" + str(u8_archive.u8_node_list[node].type) + ") was found!")
|
||||
|
||||
|
||||
def _pack_u8_dir(u8_archive: U8Archive, current_path, node_count, name_offset):
|
||||
def _pack_u8_dir(u8_archive: U8Archive, current_path, node_count, parent_node):
|
||||
# First, get the list of everything in current path.
|
||||
root_list = os.listdir(current_path)
|
||||
file_list = []
|
||||
dir_list = []
|
||||
# Create separate lists of the files and directories in the current directory so that we can handle the files first.
|
||||
# noinspection PyTypeChecker
|
||||
root_list.sort(key=str.lower)
|
||||
for path in root_list:
|
||||
if os.path.isfile(current_path.joinpath(path)):
|
||||
file_list.append(path)
|
||||
elif os.path.isdir(current_path.joinpath(path)):
|
||||
dir_list.append(path)
|
||||
# noinspection PyTypeChecker
|
||||
file_list.sort(key=str.lower)
|
||||
# noinspection PyTypeChecker
|
||||
dir_list.sort(key=str.lower)
|
||||
# For files, read their data into the file data list, add their name into the file name list, then calculate the
|
||||
# offset for their file name and create a new U8Node() for them.
|
||||
# offset for their file name and create a new U8Node() for them. -1 values are temporary and are set during dumping.
|
||||
for file in file_list:
|
||||
node_count += 1
|
||||
u8_archive.file_name_list.append(file)
|
||||
u8_archive.file_data_list.append(open(current_path.joinpath(file), "rb").read())
|
||||
u8_archive.u8_node_list.append(_U8Node(0, name_offset, 0, len(u8_archive.file_data_list[-1])))
|
||||
name_offset = name_offset + len(file) + 1 # Add 1 to accommodate the null byte at the end of the name.
|
||||
u8_archive.u8_node_list.append(_U8Node(0, -1, -1, len(u8_archive.file_data_list[-1])))
|
||||
# For directories, add their name to the file name list, add empty data to the file data list (since they obviously
|
||||
# wouldn't have any), find the total number of files and directories inside the directory to calculate the final
|
||||
# node included in it, then recursively call this function again on that directory to process it.
|
||||
@@ -256,12 +250,11 @@ def _pack_u8_dir(u8_archive: U8Archive, current_path, node_count, name_offset):
|
||||
u8_archive.file_name_list.append(directory)
|
||||
u8_archive.file_data_list.append(b'')
|
||||
max_node = node_count + sum(1 for _ in current_path.joinpath(directory).rglob('*'))
|
||||
u8_archive.u8_node_list.append(_U8Node(256, name_offset, 0, max_node))
|
||||
name_offset = name_offset + len(directory) + 1 # Add 1 to accommodate the null byte at the end of the name.
|
||||
u8_archive, node_count, name_offset = _pack_u8_dir(u8_archive, current_path.joinpath(directory), node_count,
|
||||
name_offset)
|
||||
u8_archive.u8_node_list.append(_U8Node(1, -1, parent_node, max_node))
|
||||
u8_archive, node_count = _pack_u8_dir(u8_archive, current_path.joinpath(directory), node_count,
|
||||
u8_archive.u8_node_list.index(u8_archive.u8_node_list[-1]))
|
||||
# Return the U8Archive object, the current node we're on, and the current name offset.
|
||||
return u8_archive, node_count, name_offset
|
||||
return u8_archive, node_count
|
||||
|
||||
|
||||
def pack_u8(input_path) -> bytes:
|
||||
@@ -279,34 +272,19 @@ def pack_u8(input_path) -> bytes:
|
||||
The data for the packed U8 archive.
|
||||
"""
|
||||
input_path = pathlib.Path(input_path)
|
||||
if os.path.isdir(input_path):
|
||||
if input_path.is_dir():
|
||||
# Append empty entries at the start for the root node, and then create the root U8Node() object, using rglob()
|
||||
# to read the total count of files and directories that will be packed so that we can add the total node count.
|
||||
u8_archive = U8Archive()
|
||||
u8_archive.file_name_list.append("")
|
||||
u8_archive.file_data_list.append(b'')
|
||||
u8_archive.u8_node_list.append(_U8Node(256, 0, 0, sum(1 for _ in input_path.rglob('*')) + 1))
|
||||
u8_archive.u8_node_list.append(_U8Node(1, 0, 0, sum(1 for _ in input_path.rglob('*')) + 1))
|
||||
# Call the private function _pack_u8_dir() on the root note, which will recursively call itself to pack every
|
||||
# subdirectory and file. Discard node_count and name_offset since we don't care about them here, as they're
|
||||
# really only necessary for the directory recursion.
|
||||
u8_archive, _, _ = _pack_u8_dir(u8_archive, input_path, node_count=1, name_offset=1)
|
||||
u8_archive, _ = _pack_u8_dir(u8_archive, input_path, node_count=1, parent_node=0)
|
||||
return u8_archive.dump()
|
||||
elif os.path.isfile(input_path):
|
||||
# Simple code to handle if a single file is provided as input. Not really sure *why* you'd do this, since the
|
||||
# whole point of a U8 archive is to stitch files together, but it's here nonetheless.
|
||||
with open(input_path, "rb") as f:
|
||||
u8_archive = U8Archive()
|
||||
file_name = input_path.name
|
||||
file_data = f.read()
|
||||
# Append blank file name for the root node.
|
||||
u8_archive.file_name_list.append("")
|
||||
u8_archive.file_name_list.append(file_name)
|
||||
# Append blank data for the root node.
|
||||
u8_archive.file_data_list.append(b'')
|
||||
u8_archive.file_data_list.append(file_data)
|
||||
# Append generic U8Node for the root, followed by the actual file's node.
|
||||
u8_archive.u8_node_list.append(_U8Node(256, 0, 0, 2))
|
||||
u8_archive.u8_node_list.append(_U8Node(0, 1, 0, len(file_data)))
|
||||
return u8_archive.dump()
|
||||
elif input_path.is_file():
|
||||
raise ValueError("This does not appear to be a directory.")
|
||||
else:
|
||||
raise FileNotFoundError("Input file/directory: \"" + str(input_path) + "\" does not exist!")
|
||||
raise FileNotFoundError("Input directory: \"" + str(input_path) + "\" does not exist!")
|
||||
|
||||
@@ -47,3 +47,69 @@ def _pad_bytes(data, alignment=64) -> bytes:
|
||||
while (len(data) % alignment) != 0:
|
||||
data += b'\x00'
|
||||
return data
|
||||
|
||||
|
||||
def _bitmask(x: int) -> int:
|
||||
return 1 << x
|
||||
|
||||
|
||||
_wii_menu_versions = {
|
||||
"Prelaunch": [0, 1, 2],
|
||||
"1.0J": 64,
|
||||
"1.0U": 33,
|
||||
"1.0E": 34,
|
||||
"2.0J": 128,
|
||||
"2.0U": 97,
|
||||
"2.0E": 130,
|
||||
"2.1E": 162,
|
||||
"2.2J": 192,
|
||||
"2.2U": 193,
|
||||
"2.2E": 194,
|
||||
"3.0J": 224,
|
||||
"3.0U": 225,
|
||||
"3.0E": 226,
|
||||
"3.1J": 256,
|
||||
"3.1U": 257,
|
||||
"3.1E": 258,
|
||||
"3.2J": 288,
|
||||
"3.2U": 289,
|
||||
"3.2E": 290,
|
||||
"3.3J": 352,
|
||||
"3.3U": 353,
|
||||
"3.3E": 354,
|
||||
"3.3K": 326,
|
||||
"3.4J": 384,
|
||||
"3.4U": 385,
|
||||
"3.4E": 386,
|
||||
"3.5K": 390,
|
||||
"4.0J": 416,
|
||||
"4.0U": 417,
|
||||
"4.0E": 418,
|
||||
"4.1J": 448,
|
||||
"4.1U": 449,
|
||||
"4.1E": 450,
|
||||
"4.1K": 454,
|
||||
"4.2J": 480,
|
||||
"4.2U": 481,
|
||||
"4.2E": 482,
|
||||
"4.2K": 486,
|
||||
"4.3J": 512,
|
||||
"4.3U": 513,
|
||||
"4.3E": 514,
|
||||
"4.3K": 518,
|
||||
"4.3U-Mini": 4609,
|
||||
"4.3E-Mini": 4610
|
||||
}
|
||||
|
||||
|
||||
_vwii_menu_versions = {
|
||||
"vWii-1.0.0J": 512,
|
||||
"vWii-1.0.0U": 513,
|
||||
"vWii-1.0.0E": 514,
|
||||
"vWii-4.0.0J": 544,
|
||||
"vWii-4.0.0U": 545,
|
||||
"vWii-4.0.0E": 546,
|
||||
"vWii-5.2.0J": 608,
|
||||
"vWii-5.2.0U": 609,
|
||||
"vWii-5.2.0E": 610,
|
||||
}
|
||||
|
||||
@@ -3,8 +3,12 @@
|
||||
|
||||
from .content import *
|
||||
from .crypto import *
|
||||
from .emunand import *
|
||||
from .iospatcher import *
|
||||
from .nus import *
|
||||
from .sys import *
|
||||
from .ticket import *
|
||||
from .title import *
|
||||
from .tmd import *
|
||||
from .util import *
|
||||
from .wad import *
|
||||
|
||||
@@ -10,7 +10,8 @@ vwii_key = '30bfc76e7c19afbb23163330ced7c28d'
|
||||
|
||||
def get_common_key(common_key_index) -> bytes:
|
||||
"""
|
||||
Gets the specified Wii Common Key based on the index provided.
|
||||
Gets the specified Wii Common Key based on the index provided. If an invalid common key index is provided, this
|
||||
function falls back on always returning key 0 (the Common Key).
|
||||
|
||||
Possible values for common_key_index: 0: Common Key, 1: Korean Key, 2: vWii Key
|
||||
|
||||
@@ -32,5 +33,5 @@ def get_common_key(common_key_index) -> bytes:
|
||||
case 2:
|
||||
common_key_bin = binascii.unhexlify(vwii_key)
|
||||
case _:
|
||||
raise ValueError("The common key index provided, " + str(common_key_index) + ", does not exist.")
|
||||
common_key_bin = binascii.unhexlify(common_key)
|
||||
return common_key_bin
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
#
|
||||
# See https://wiibrew.org/wiki/Title for details about how titles are formatted
|
||||
|
||||
import binascii
|
||||
import io
|
||||
import hashlib
|
||||
from typing import List
|
||||
from dataclasses import dataclass as _dataclass
|
||||
from ..types import _ContentRecord
|
||||
from ..shared import _pad_bytes, _align_value
|
||||
from .crypto import decrypt_content, encrypt_content
|
||||
@@ -84,7 +86,7 @@ class ContentRegion:
|
||||
content_region_data = b''
|
||||
for content in self.content_list:
|
||||
# If this isn't the first content, pad the whole region to 64 bytes before the next one.
|
||||
if content_region_data is not b'':
|
||||
if content_region_data != b'':
|
||||
content_region_data = _pad_bytes(content_region_data, 64)
|
||||
# Calculate padding after this content before the next one.
|
||||
padding_bytes = 0
|
||||
@@ -107,6 +109,10 @@ class ContentRegion:
|
||||
"""
|
||||
Gets an individual content from the content region based on the provided index, in encrypted form.
|
||||
|
||||
This uses the content index, which is the value tied to each content and used as the IV for encryption, rather
|
||||
than the literal index in the array of content, because sometimes the contents end up out of order in a WAD
|
||||
while still retaining the original indices.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
index : int
|
||||
@@ -117,7 +123,17 @@ class ContentRegion:
|
||||
bytes
|
||||
The encrypted content listed in the content record.
|
||||
"""
|
||||
content_enc = self.content_list[index]
|
||||
# Get a list of the current content indices, so we can make sure the target one exists. Doing it this way
|
||||
# ensures we can find the target, even if the highest content index is greater than the highest literal index.
|
||||
current_indices = []
|
||||
for record in self.content_records:
|
||||
current_indices.append(record.index)
|
||||
if index not in current_indices:
|
||||
raise ValueError("You are trying to get the content at index " + str(index) + ", but no content with that "
|
||||
"index exists!")
|
||||
# This is the literal index in the list of content that we're going to get.
|
||||
target_index = current_indices.index(index)
|
||||
content_enc = self.content_list[target_index]
|
||||
return content_enc
|
||||
|
||||
def get_enc_content_by_cid(self, cid: int) -> bytes:
|
||||
@@ -127,23 +143,23 @@ class ContentRegion:
|
||||
Parameters
|
||||
----------
|
||||
cid : int
|
||||
The Content ID of the content you want to get. Expected to be in decimal form.
|
||||
The Content ID of the content you want to get. Expected to be in decimal form, not hex.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bytes
|
||||
The encrypted content listed in the content record.
|
||||
"""
|
||||
# Find the index of the requested Content ID.
|
||||
content_index = None
|
||||
for content in self.content_records:
|
||||
if content.content_id == cid:
|
||||
content_index = content.index
|
||||
# If finding a matching ID was unsuccessful, that means that no content with that ID is in the TMD, so
|
||||
# return a Value Error.
|
||||
if content_index is None:
|
||||
raise ValueError("The Content ID requested does not exist in the TMD's content records.")
|
||||
# Call get_enc_content_by_index() using the index we just found.
|
||||
# Get a list of the current Content IDs, so we can make sure the target one exists.
|
||||
content_ids = []
|
||||
for record in self.content_records:
|
||||
content_ids.append(record.content_id)
|
||||
if cid not in content_ids:
|
||||
raise ValueError("You are trying to get a content with Content ID " + str(cid) + ", but no content with "
|
||||
"that ID exists!")
|
||||
# Get the content index associated with the CID we now know exists.
|
||||
target_index = content_ids.index(cid)
|
||||
content_index = self.content_records[target_index].index
|
||||
content_enc = self.get_enc_content_by_index(content_index)
|
||||
return content_enc
|
||||
|
||||
@@ -158,68 +174,84 @@ class ContentRegion:
|
||||
"""
|
||||
return self.content_list
|
||||
|
||||
def get_content_by_index(self, index: int, title_key: bytes) -> bytes:
|
||||
def get_content_by_index(self, index: int, title_key: bytes, skip_hash=False) -> bytes:
|
||||
"""
|
||||
Gets an individual content from the content region based on the provided index, in decrypted form.
|
||||
|
||||
This uses the content index, which is the value tied to each content and used as the IV for encryption, rather
|
||||
than the literal index in the array of content, because sometimes the contents end up out of order in a WAD
|
||||
while still retaining the original indices.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
index : int
|
||||
The index of the content you want to get.
|
||||
The content index of the content you want to get.
|
||||
title_key : bytes
|
||||
The Title Key for the title the content is from.
|
||||
skip_hash : bool, optional
|
||||
Skip the hash check and return the content regardless of its hash. Defaults to false.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bytes
|
||||
The decrypted content listed in the content record.
|
||||
"""
|
||||
# Load the encrypted content at the specified index and then decrypt it with the Title Key.
|
||||
# Get a list of the current content indices, so we can make sure the target one exists. Doing it this way
|
||||
# ensures we can find the target, even if the highest content index is greater than the highest literal index.
|
||||
current_indices = []
|
||||
for record in self.content_records:
|
||||
current_indices.append(record.index)
|
||||
# This is the literal index in the list of content that we're going to get.
|
||||
target_index = current_indices.index(index)
|
||||
content_enc = self.get_enc_content_by_index(index)
|
||||
content_dec = decrypt_content(content_enc, title_key, self.content_records[index].index,
|
||||
self.content_records[index].content_size)
|
||||
content_dec = decrypt_content(content_enc, title_key, index, self.content_records[target_index].content_size)
|
||||
# Hash the decrypted content and ensure that the hash matches the one in its Content Record.
|
||||
# If it does not, then something has gone wrong in the decryption, and an error will be thrown.
|
||||
content_dec_hash = hashlib.sha1(content_dec).hexdigest()
|
||||
content_record_hash = str(self.content_records[index].content_hash.decode())
|
||||
content_record_hash = str(self.content_records[target_index].content_hash.decode())
|
||||
# Compare the hash and throw a ValueError if the hash doesn't match.
|
||||
if content_dec_hash != content_record_hash:
|
||||
raise ValueError("Content hash did not match the expected hash in its record! The incorrect Title Key may "
|
||||
"have been used!\n"
|
||||
"Expected hash is: {}\n".format(content_record_hash) +
|
||||
"Actual hash is: {}".format(content_dec_hash))
|
||||
if skip_hash:
|
||||
print("Ignoring hash mismatch for content index " + str(index))
|
||||
else:
|
||||
raise ValueError("Content hash did not match the expected hash in its record! The incorrect Title Key "
|
||||
"may have been used!\n"
|
||||
"Expected hash is: {}\n".format(content_record_hash) +
|
||||
"Actual hash is: {}".format(content_dec_hash))
|
||||
return content_dec
|
||||
|
||||
def get_content_by_cid(self, cid: int, title_key: bytes) -> bytes:
|
||||
def get_content_by_cid(self, cid: int, title_key: bytes, skip_hash=False) -> bytes:
|
||||
"""
|
||||
Gets an individual content from the content region based on the provided Content ID, in decrypted form.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
cid : int
|
||||
The Content ID of the content you want to get. Expected to be in decimal form.
|
||||
The Content ID of the content you want to get. Expected to be in decimal form, not hex.
|
||||
title_key : bytes
|
||||
The Title Key for the title the content is from.
|
||||
skip_hash : bool, optional
|
||||
Skip the hash check and return the content regardless of its hash. Defaults to false.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bytes
|
||||
The decrypted content listed in the content record.
|
||||
"""
|
||||
# Find the index of the requested Content ID.
|
||||
content_index = None
|
||||
for content in self.content_records:
|
||||
if content.content_id == cid:
|
||||
content_index = content.index
|
||||
# If finding a matching ID was unsuccessful, that means that no content with that ID is in the TMD, so
|
||||
# return a Value Error.
|
||||
if content_index is None:
|
||||
raise ValueError("The Content ID requested does not exist in the TMD's content records.")
|
||||
# Call get_content_by_index() using the index we just found.
|
||||
content_dec = self.get_content_by_index(content_index, title_key)
|
||||
# Get a list of the current Content IDs, so we can make sure the target one exists.
|
||||
content_ids = []
|
||||
for record in self.content_records:
|
||||
content_ids.append(record.content_id)
|
||||
if cid not in content_ids:
|
||||
raise ValueError("You are trying to get a content with Content ID " + str(cid) + ", but no content with "
|
||||
"that ID exists!")
|
||||
# Get the content index associated with the CID we now know exists.
|
||||
target_index = content_ids.index(cid)
|
||||
content_index = self.content_records[target_index].index
|
||||
content_dec = self.get_content_by_index(content_index, title_key, skip_hash)
|
||||
return content_dec
|
||||
|
||||
def get_contents(self, title_key: bytes) -> List[bytes]:
|
||||
def get_contents(self, title_key: bytes, skip_hash=False) -> List[bytes]:
|
||||
"""
|
||||
Gets a list of all contents from the content region, in decrypted form.
|
||||
|
||||
@@ -227,6 +259,8 @@ class ContentRegion:
|
||||
----------
|
||||
title_key : bytes
|
||||
The Title Key for the title the content is from.
|
||||
skip_hash : bool, optional
|
||||
Skip the hash check and return the content regardless of its hash. Defaults to false.
|
||||
|
||||
Returns
|
||||
-------
|
||||
@@ -236,19 +270,19 @@ class ContentRegion:
|
||||
dec_contents: List[bytes] = []
|
||||
# Iterate over every content, get the decrypted version of it, then add it to a list and return it.
|
||||
for content in range(self.num_contents):
|
||||
dec_contents.append(self.get_content_by_index(content, title_key))
|
||||
dec_contents.append(self.get_content_by_index(content, title_key, skip_hash))
|
||||
return dec_contents
|
||||
|
||||
def set_enc_content(self, enc_content: bytes, cid: int, index: int, content_type: int, content_size: int,
|
||||
def add_enc_content(self, enc_content: bytes, cid: int, index: int, content_type: int, content_size: int,
|
||||
content_hash: bytes) -> None:
|
||||
"""
|
||||
Sets the provided index to a new content with the provided Content ID. Hashes and size of the content are
|
||||
set in the content record, with a new record being added if necessary.
|
||||
Adds a new encrypted content to the ContentRegion, and adds the provided Content ID, index, content type,
|
||||
content size, and content hash to a new record in the ContentRecord list.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
enc_content : bytes
|
||||
The new encrypted content to set.
|
||||
The new encrypted content to add.
|
||||
cid : int
|
||||
The Content ID to assign the new content in the content record.
|
||||
index : int
|
||||
@@ -260,54 +294,120 @@ class ContentRegion:
|
||||
content_hash : bytes
|
||||
The hash of the new encrypted content when decrypted.
|
||||
"""
|
||||
# Save the number of contents currently in the content region and records.
|
||||
num_contents = len(self.content_records)
|
||||
# Check if a record already exists for this index. If it doesn't, create it.
|
||||
if (index + 1) > num_contents:
|
||||
# Ensure that you aren't attempting to create a gap before appending.
|
||||
if (index + 1) > num_contents + 1:
|
||||
raise ValueError("You are trying to set the content at position " + str(index) + ", but no content "
|
||||
"exists at position " + str(index - 1) + "!")
|
||||
self.content_records.append(_ContentRecord(cid, index, content_type, content_size, content_hash))
|
||||
# If it does, reassign the values in it.
|
||||
else:
|
||||
self.content_records[index].content_id = cid
|
||||
self.content_records[index].content_type = content_type
|
||||
self.content_records[index].content_size = content_size
|
||||
self.content_records[index].content_hash = content_hash
|
||||
# Check if a content already occupies the provided index. If it does, reassign it to the new content, if it
|
||||
# doesn't, then append a new entry.
|
||||
if (index + 1) > num_contents:
|
||||
self.content_list.append(enc_content)
|
||||
else:
|
||||
self.content_list[index] = enc_content
|
||||
# Check to make sure this isn't reusing an already existing Content ID or index first.
|
||||
for record in self.content_records:
|
||||
if record.content_id == cid:
|
||||
raise ValueError("Content with a Content ID of " + str(cid) + " already exists!")
|
||||
elif record.index == index:
|
||||
raise ValueError("Content with an index of " + str(index) + " already exists!")
|
||||
# If we're good, then append all the data and create a new ContentRecord().
|
||||
self.content_list.append(enc_content)
|
||||
self.content_records.append(_ContentRecord(cid, index, content_type, content_size, content_hash))
|
||||
|
||||
def set_content(self, dec_content: bytes, cid: int, index: int, content_type: int, title_key: bytes) -> None:
|
||||
def add_content(self, dec_content: bytes, cid: int, index: int, content_type: int, title_key: bytes) -> None:
|
||||
"""
|
||||
Sets the provided index to a new content with the provided Content ID. Hashes and size of the content are
|
||||
set in the content record, with a new record being added if necessary.
|
||||
Adds a new decrypted content to the ContentRegion, and adds the provided Content ID, index, content type,
|
||||
content size, and content hash to a new record in the ContentRecord list.
|
||||
|
||||
This first gets the content hash and size from the provided data, and then encrypts the content with the
|
||||
provided Title Key before adding it to the ContentRegion.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
dec_content : bytes
|
||||
The new decrypted content to set.
|
||||
The new decrypted content to add.
|
||||
cid : int
|
||||
The Content ID to assign the new content in the content record.
|
||||
index : int
|
||||
The index to place the new content at.
|
||||
content_type : int
|
||||
The type of the new content.
|
||||
title_key : bytes
|
||||
The Title Key that matches the other content in the ContentRegion.
|
||||
"""
|
||||
content_size = len(dec_content)
|
||||
content_hash = str.encode(hashlib.sha1(dec_content).hexdigest())
|
||||
enc_content = encrypt_content(dec_content, title_key, index)
|
||||
self.add_enc_content(enc_content, cid, index, content_type, content_size, content_hash)
|
||||
|
||||
def set_enc_content(self, enc_content: bytes, index: int, content_size: int, content_hash: bytes, cid: int = None,
|
||||
content_type: int = None) -> None:
|
||||
"""
|
||||
Sets the content at the provided content index to the provided new encrypted content. The provided hash and
|
||||
content size are set in the corresponding content record. A new Content ID or content type can also be
|
||||
specified, but if it isn't than the current values are preserved.
|
||||
|
||||
This uses the content index, which is the value tied to each content and used as the IV for encryption, rather
|
||||
than the literal index in the array of content, because sometimes the contents end up out of order in a WAD
|
||||
while still retaining the original indices.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
enc_content : bytes
|
||||
The new encrypted content to set.
|
||||
index : int
|
||||
The target content index to set the new content at.
|
||||
content_size : int
|
||||
The size of the new encrypted content when decrypted.
|
||||
content_hash : bytes
|
||||
The hash of the new encrypted content when decrypted.
|
||||
cid : int, optional
|
||||
The Content ID to assign the new content in the content record. Current value will be preserved if not set.
|
||||
content_type : int, optional
|
||||
The type of the new content. Current value will be preserved if not set.
|
||||
"""
|
||||
# Get a list of the current content indices, so we can make sure the target one exists. Doing it this way
|
||||
# ensures we can find the target, even if the highest content index is greater than the highest literal index.
|
||||
current_indices = []
|
||||
for record in self.content_records:
|
||||
current_indices.append(record.index)
|
||||
if index not in current_indices:
|
||||
raise ValueError("You are trying to set the content at index " + str(index) + ", but no content with that "
|
||||
"index currently exists!")
|
||||
# This is the literal index in the list of content/content records that we're going to change.
|
||||
target_index = current_indices.index(index)
|
||||
# Reassign the values, but only set the optional ones if they were passed.
|
||||
self.content_records[target_index].content_size = content_size
|
||||
self.content_records[target_index].content_hash = content_hash
|
||||
if cid is not None:
|
||||
self.content_records[target_index].content_id = cid
|
||||
if content_type is not None:
|
||||
self.content_records[target_index].content_type = content_type
|
||||
# Add blank entries to the list to ensure that its length matches the length of the content record list.
|
||||
while len(self.content_list) < len(self.content_records):
|
||||
self.content_list.append(b'')
|
||||
self.content_list[target_index] = enc_content
|
||||
|
||||
def set_content(self, dec_content: bytes, index: int, title_key: bytes, cid: int = None,
|
||||
content_type: int = None) -> None:
|
||||
"""
|
||||
Sets the content at the provided content index to the provided new decrypted content. The hash and content size
|
||||
of this content will be generated and then set in the corresponding content record. A new Content ID or content
|
||||
type can also be specified, but if it isn't than the current values are preserved.
|
||||
|
||||
The provided Title Key is used to encrypt the content so that it can be set in the ContentRegion.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
dec_content : bytes
|
||||
The new decrypted content to set.
|
||||
index : int
|
||||
The index to place the new content at.
|
||||
title_key : bytes
|
||||
The Title Key that matches the new decrypted content.
|
||||
cid : int
|
||||
The Content ID to assign the new content in the content record.
|
||||
content_type : int
|
||||
The type of the new content.
|
||||
"""
|
||||
# Store the size of the new content.
|
||||
dec_content_size = len(dec_content)
|
||||
content_size = len(dec_content)
|
||||
# Calculate the hash of the new content.
|
||||
dec_content_hash = str.encode(hashlib.sha1(dec_content).hexdigest())
|
||||
content_hash = str.encode(hashlib.sha1(dec_content).hexdigest())
|
||||
# Encrypt the content using the provided Title Key and index.
|
||||
enc_content = encrypt_content(dec_content, title_key, index)
|
||||
# Pass values to set_enc_content()
|
||||
self.set_enc_content(enc_content, cid, index, content_type, dec_content_size, dec_content_hash)
|
||||
self.set_enc_content(enc_content, index, content_size, content_hash, cid, content_type)
|
||||
|
||||
def load_enc_content(self, enc_content: bytes, index: int) -> None:
|
||||
"""
|
||||
@@ -315,6 +415,10 @@ class ContentRegion:
|
||||
it matches the record at that index. Not recommended for most use cases, use decrypted content and
|
||||
load_content() instead.
|
||||
|
||||
This uses the content index, which is the value tied to each content and used as the IV for encryption, rather
|
||||
than the literal index in the array of content, because sometimes the contents end up out of order in a WAD
|
||||
while still retaining the original indices.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
enc_content : bytes
|
||||
@@ -322,18 +426,30 @@ class ContentRegion:
|
||||
index : int
|
||||
The content index to load the content at.
|
||||
"""
|
||||
if (index + 1) > len(self.content_records) or len(self.content_records) == 0:
|
||||
raise IndexError("No content records have been loaded, or that index is higher than the highest entry in "
|
||||
"the content records.")
|
||||
if (index + 1) > len(self.content_list):
|
||||
self.content_list.append(enc_content)
|
||||
else:
|
||||
self.content_list[index] = enc_content
|
||||
# Get a list of the current content indices, so we can make sure the target one exists. Doing it this way
|
||||
# ensures we can find the target, even if the highest content index is greater than the highest literal index.
|
||||
current_indices = []
|
||||
for record in self.content_records:
|
||||
current_indices.append(record.index)
|
||||
if index not in current_indices:
|
||||
raise ValueError("You are trying to load the content at index " + str(index) + ", but no content with that "
|
||||
"index currently exists! Make sure the correct content records have been loaded.")
|
||||
# Add blank entries to the list to ensure that its length matches the length of the content record list.
|
||||
while len(self.content_list) < len(self.content_records):
|
||||
self.content_list.append(b'')
|
||||
# This is the literal index in the list of content/content records that we're going to change.
|
||||
target_index = current_indices.index(index)
|
||||
self.content_list[target_index] = enc_content
|
||||
|
||||
def load_content(self, dec_content: bytes, index: int, title_key: bytes) -> None:
|
||||
"""
|
||||
Loads the provided decrypted content into the content region at the specified index, but first checks to make
|
||||
sure it matches the record at that index before loading. This content will be encrypted when loaded.
|
||||
Loads the provided decrypted content into the ContentRegion at the specified index, but first checks to make
|
||||
sure that it matches the corresponding record. This content will then be encrypted using the provided Title Key
|
||||
before being loaded.
|
||||
|
||||
This uses the content index, which is the value tied to each content and used as the IV for encryption, rather
|
||||
than the literal index in the array of content, because sometimes the contents end up out of order in a WAD
|
||||
while still retaining the original indices.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
@@ -344,19 +460,136 @@ class ContentRegion:
|
||||
title_key: bytes
|
||||
The Title Key that matches the decrypted content.
|
||||
"""
|
||||
# Make sure that content records exist and that the provided index exists in them.
|
||||
if (index + 1) > len(self.content_records) or len(self.content_records) == 0:
|
||||
raise IndexError("No content records have been loaded, or that index is higher than the highest entry in "
|
||||
"the content records.")
|
||||
# Get a list of the current content indices, so we can make sure the target one exists. Doing it this way
|
||||
# ensures we can find the target, even if the highest content index is greater than the highest literal index.
|
||||
current_indices = []
|
||||
for record in self.content_records:
|
||||
current_indices.append(record.index)
|
||||
if index not in current_indices:
|
||||
raise ValueError("You are trying to load the content at index " + str(index) + ", but no content with that "
|
||||
"index currently exists! Make sure the correct content records have been loaded.")
|
||||
# This is the literal index in the list of content/content records that we're going to change.
|
||||
target_index = current_indices.index(index)
|
||||
# Check the hash of the content against the hash stored in the record to ensure it matches.
|
||||
content_hash = hashlib.sha1(dec_content).hexdigest()
|
||||
if content_hash != self.content_records[index].content_hash.decode():
|
||||
if content_hash != self.content_records[target_index].content_hash.decode():
|
||||
raise ValueError("The decrypted content provided does not match the record at the provided index. \n"
|
||||
"Expected hash is: {}\n".format(self.content_records[index].content_hash.decode()) +
|
||||
"Actual hash is: {}".format(content_hash))
|
||||
# Add blank entries to the list to ensure that its length matches the length of the content record list.
|
||||
while len(self.content_list) < len(self.content_records):
|
||||
self.content_list.append(b'')
|
||||
# If the hash matches, encrypt the content and set it where it belongs.
|
||||
# This uses the index from the content records instead of just the index given, because there are some strange
|
||||
# circumstances where the actual index in the array and the assigned content index don't match up, and this
|
||||
# needs to accommodate that. Seems to only apply to custom WADs ? (Like cIOS WADs?)
|
||||
enc_content = encrypt_content(dec_content, title_key, index)
|
||||
if (index + 1) > len(self.content_list):
|
||||
self.content_list.append(enc_content)
|
||||
self.content_list[target_index] = enc_content
|
||||
|
||||
|
||||
@_dataclass
|
||||
class _SharedContentRecord:
|
||||
"""
|
||||
A _SharedContentRecord object used to store the data of a specific content stored in /shared1/. Private class used
|
||||
by the content module.
|
||||
|
||||
Attributes
|
||||
----------
|
||||
shared_id : str
|
||||
The incremental ID used to store the shared content.
|
||||
content_hash : bytes
|
||||
The SHA-1 hash of the shared content.
|
||||
"""
|
||||
shared_id: str
|
||||
content_hash: bytes
|
||||
|
||||
|
||||
class SharedContentMap:
|
||||
"""
|
||||
A SharedContentMap object to parse and edit the content.map file stored in /shared1/ on the Wii's NAND. This file is
|
||||
used to keep track of all shared contents installed on the console.
|
||||
|
||||
Attributes
|
||||
----------
|
||||
shared_records : List[_SharedContentRecord]
|
||||
The shared content records stored in content.map.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.shared_records: List[_SharedContentRecord] = []
|
||||
|
||||
def load(self, content_map: bytes) -> None:
|
||||
"""
|
||||
Loads the raw content map and parses the records in it.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
content_map : bytes
|
||||
The data of a content.map file.
|
||||
"""
|
||||
# Sanity check to ensure the length is divisible by 28 bytes. If it isn't, then it is malformed.
|
||||
if (len(content_map) % 28) != 0:
|
||||
raise ValueError("The provided content map appears to be corrupted!")
|
||||
entry_count = len(content_map) // 28
|
||||
with io.BytesIO(content_map) as map_data:
|
||||
for i in range(entry_count):
|
||||
shared_id = str(map_data.read(8).decode())
|
||||
content_hash = binascii.hexlify(map_data.read(20))
|
||||
self.shared_records.append(_SharedContentRecord(shared_id, content_hash))
|
||||
|
||||
def dump(self) -> bytes:
|
||||
"""
|
||||
Dumps the SharedContentMap object back into a content.map file.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bytes
|
||||
The raw data of the content.map file.
|
||||
"""
|
||||
map_data = b''
|
||||
for record in self.shared_records:
|
||||
map_data += record.shared_id.encode()
|
||||
map_data += binascii.unhexlify(record.content_hash)
|
||||
return map_data
|
||||
|
||||
def add_content(self, content_hash: str | bytes) -> str:
|
||||
"""
|
||||
Adds a new shared content SHA-1 hash to the content map and returns the file name assigned to that hash.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
content_hash : str, bytes
|
||||
The SHA-1 hash of the new shared content.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
The filename assigned to the provided content hash.
|
||||
"""
|
||||
if type(content_hash) is bytes:
|
||||
# This catches the format b'GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG'
|
||||
if len(content_hash) == 40:
|
||||
content_hash_converted = content_hash
|
||||
# This catches the format
|
||||
# b'\xGG\xGG\xGG\xGG\xGG\xGG\xGG\xGG\xGG\xGG\xGG\xGG\xGG\xGG\xGG\xGG\xGG\xGG\xGG\xGG'
|
||||
elif len(content_hash) == 20:
|
||||
content_hash_converted = binascii.hexlify(content_hash)
|
||||
# If it isn't one of those lengths, it cannot possibly be valid, so reject it.
|
||||
else:
|
||||
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:
|
||||
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
|
||||
|
||||
|
||||
def _convert_tid_to_iv(title_id: str) -> bytes:
|
||||
def _convert_tid_to_iv(title_id: str | bytes) -> bytes:
|
||||
# Converts a Title ID in various formats into the format required to act as an IV. Private function used by other
|
||||
# crypto functions.
|
||||
title_key_iv = b''
|
||||
@@ -17,7 +17,7 @@ def _convert_tid_to_iv(title_id: str) -> bytes:
|
||||
title_key_iv = binascii.unhexlify(title_id)
|
||||
# This catches the format b'\x00\x00\x00\x01\x00\x00\x00\x02'
|
||||
elif len(title_id) == 8:
|
||||
pass
|
||||
title_key_iv = title_id
|
||||
# If it isn't one of those lengths, it cannot possibly be valid, so reject it.
|
||||
else:
|
||||
raise ValueError("Title ID is not valid!")
|
||||
|
||||
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")
|
||||
@@ -5,9 +5,11 @@
|
||||
|
||||
import io
|
||||
import binascii
|
||||
import hashlib
|
||||
from dataclasses import dataclass as _dataclass
|
||||
from .crypto import decrypt_title_key
|
||||
from typing import List
|
||||
from .util import title_ver_standard_to_dec
|
||||
|
||||
|
||||
@_dataclass
|
||||
@@ -65,7 +67,6 @@ class Ticket:
|
||||
self.ticket_id: bytes = b'' # Used as the IV when decrypting the title key for console-specific title installs.
|
||||
self.console_id: int = 0 # ID of the console that the ticket was issued for.
|
||||
self.title_id: bytes = b'' # TID/IV used for AES-CBC encryption.
|
||||
self.title_id_str: str = "" # TID in string form for comparing against the TMD.
|
||||
self.unknown1: bytes = b'' # Some unknown data, not always the same so reading it just in case.
|
||||
self.title_version: int = 0 # Version of the ticket's associated title.
|
||||
self.permitted_titles: bytes = b'' # Permitted titles mask
|
||||
@@ -124,17 +125,12 @@ class Ticket:
|
||||
# Title ID.
|
||||
ticket_data.seek(0x1DC)
|
||||
self.title_id = binascii.hexlify(ticket_data.read(8))
|
||||
# Title ID (as a string).
|
||||
self.title_id_str = str(self.title_id.decode())
|
||||
# Unknown data 1.
|
||||
ticket_data.seek(0x1E4)
|
||||
self.unknown1 = ticket_data.read(2)
|
||||
# Title version.
|
||||
ticket_data.seek(0x1E6)
|
||||
title_version_high = int.from_bytes(ticket_data.read(1)) * 256
|
||||
ticket_data.seek(0x1E7)
|
||||
title_version_low = int.from_bytes(ticket_data.read(1))
|
||||
self.title_version = title_version_high + title_version_low
|
||||
self.title_version = int.from_bytes(ticket_data.read(2))
|
||||
# Permitted titles mask.
|
||||
ticket_data.seek(0x1E8)
|
||||
self.permitted_titles = ticket_data.read(4)
|
||||
@@ -162,8 +158,7 @@ class Ticket:
|
||||
|
||||
def dump(self) -> bytes:
|
||||
"""
|
||||
Dumps the Ticket object back into bytes. This also sets the raw Ticket attribute of Ticket object to the
|
||||
dumped data, and triggers load() again to ensure that the raw data and object match.
|
||||
Dumps the Ticket object back into bytes.
|
||||
|
||||
Returns
|
||||
-------
|
||||
@@ -198,10 +193,7 @@ class Ticket:
|
||||
# Unknown data 1.
|
||||
ticket_data += self.unknown1
|
||||
# Title version.
|
||||
title_version_high = round(self.title_version / 256)
|
||||
ticket_data += int.to_bytes(title_version_high, 1)
|
||||
title_version_low = self.title_version % 256
|
||||
ticket_data += int.to_bytes(title_version_low, 1)
|
||||
ticket_data += int.to_bytes(self.title_version, 2)
|
||||
# Permitted titles mask.
|
||||
ticket_data += self.permitted_titles
|
||||
# Permit mask.
|
||||
@@ -226,6 +218,61 @@ class Ticket:
|
||||
ticket_data += title_limit_data
|
||||
return ticket_data
|
||||
|
||||
def fakesign(self) -> None:
|
||||
"""
|
||||
Fakesigns this Ticket for the trucha bug.
|
||||
|
||||
This is done by brute-forcing a Ticket body hash starting with 00, causing it to pass signature verification on
|
||||
older IOS versions that incorrectly check the hash using strcmp() instead of memcmp(). The signature will also
|
||||
be erased and replaced with all NULL bytes.
|
||||
|
||||
The hash is brute-forced by using the first two bytes of an unused section of the Ticket as a 16-bit integer,
|
||||
and incrementing that value by 1 until an appropriate hash is found.
|
||||
|
||||
This modifies the Ticket object in place. You will need to call this method after any changes, and before
|
||||
dumping the Ticket object back into bytes.
|
||||
"""
|
||||
# Clear the signature, so that the hash derived from it is guaranteed to always be
|
||||
# '0000000000000000000000000000000000000000'.
|
||||
self.signature = b'\x00' * 256
|
||||
current_int = 0
|
||||
test_hash = ''
|
||||
while test_hash[:2] != '00':
|
||||
current_int += 1
|
||||
# We're using the first 2 bytes of this unused region of the Ticket as a 16-bit integer, and incrementing
|
||||
# that to brute-force the hash we need.
|
||||
data_to_edit = self.unknown2
|
||||
data_to_edit = int.to_bytes(current_int, 2) + data_to_edit[2:]
|
||||
self.unknown2 = data_to_edit
|
||||
# Trim off the first 320 bytes, because we're only looking for the hash of the Ticket's body.
|
||||
# This is a try-except because an OverflowError will be thrown if the number being used to brute-force the
|
||||
# hash gets too big, as it is only a 16-bit integer. If that happens, then fakesigning has failed.
|
||||
try:
|
||||
test_hash = hashlib.sha1(self.dump()[320:]).hexdigest()
|
||||
except OverflowError:
|
||||
raise Exception("An error occurred during fakesigning. Ticket could not be fakesigned!")
|
||||
|
||||
def get_is_fakesigned(self) -> bool:
|
||||
"""
|
||||
Checks the Ticket object to see if it is currently fakesigned. For a description of fakesigning, refer to the
|
||||
fakesign() method.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bool:
|
||||
True if the Ticket is fakesigned, False otherwise.
|
||||
|
||||
See Also
|
||||
--------
|
||||
libWiiPy.title.ticket.Ticket.fakesign()
|
||||
"""
|
||||
if self.signature != b'\x00' * 256:
|
||||
return False
|
||||
test_hash = hashlib.sha1(self.dump()[320:]).hexdigest()
|
||||
if test_hash[:2] != '00':
|
||||
return False
|
||||
return True
|
||||
|
||||
def get_title_id(self) -> str:
|
||||
"""
|
||||
Gets the Title ID of the ticket's associated title.
|
||||
@@ -249,7 +296,7 @@ class Ticket:
|
||||
|
||||
See Also
|
||||
--------
|
||||
commonkeys.get_common_key
|
||||
libWiiPy.title.commonkeys.get_common_key
|
||||
"""
|
||||
match self.common_key_index:
|
||||
case 0:
|
||||
@@ -273,7 +320,8 @@ class Ticket:
|
||||
|
||||
def set_title_id(self, title_id) -> None:
|
||||
"""
|
||||
Sets the Title ID of the title in the Ticket.
|
||||
Sets the Title ID property of the Ticket. Recommended over setting the property directly because of input
|
||||
validation.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
@@ -282,5 +330,34 @@ class Ticket:
|
||||
"""
|
||||
if len(title_id) != 16:
|
||||
raise ValueError("Invalid Title ID! Title IDs must be 8 bytes long.")
|
||||
self.title_id_str = title_id
|
||||
self.title_id = binascii.unhexlify(title_id)
|
||||
self.title_id = title_id.encode()
|
||||
|
||||
def set_title_version(self, new_version: str | int) -> None:
|
||||
"""
|
||||
Sets the version of the title in the Ticket. Recommended over setting the data directly because of input
|
||||
validation.
|
||||
|
||||
Accepts either standard form (vX.X) as a string or decimal form (vXXX) as an integer.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
new_version : str, int
|
||||
The new version of the title. See description for valid formats.
|
||||
"""
|
||||
if type(new_version) is str:
|
||||
# Validate string input is in the correct format, then validate that the version isn't higher than v255.0.
|
||||
# If checks pass, convert to decimal form and set that as the title version.
|
||||
version_str_split = new_version.split(".")
|
||||
if len(version_str_split) != 2:
|
||||
raise ValueError("Title version is not valid! String version must be entered in format \"X.X\".")
|
||||
if int(version_str_split[0]) > 255 or int(version_str_split[1]) > 255:
|
||||
raise ValueError("Title version is not valid! String version number cannot exceed v255.255.")
|
||||
version_converted = title_ver_standard_to_dec(new_version, str(self.title_id.decode()))
|
||||
self.title_version = version_converted
|
||||
elif type(new_version) is int:
|
||||
# Validate that the version isn't higher than v65280. If the check passes, set that as the title version.
|
||||
if new_version > 65535:
|
||||
raise ValueError("Title version is not valid! Integer version number cannot exceed v65535.")
|
||||
self.title_version = new_version
|
||||
else:
|
||||
raise TypeError("Title version type is not valid! Type must be either integer or string.")
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
#
|
||||
# See https://wiibrew.org/wiki/Title for details about how titles are formatted
|
||||
|
||||
import math
|
||||
from .content import ContentRegion
|
||||
from .ticket import Ticket
|
||||
from .tmd import TMD
|
||||
from .wad import WAD
|
||||
from .crypto import encrypt_title_key
|
||||
|
||||
|
||||
class Title:
|
||||
@@ -56,7 +58,7 @@ class Title:
|
||||
self.content.load(self.wad.get_content_data(), self.tmd.content_records)
|
||||
# Ensure that the Title IDs of the TMD and Ticket match before doing anything else. If they don't, throw an
|
||||
# error because clearly something strange has gone on with the WAD and editing it probably won't work.
|
||||
if self.tmd.title_id != self.ticket.title_id_str:
|
||||
if self.tmd.title_id != str(self.ticket.title_id.decode()):
|
||||
raise ValueError("The Title IDs of the TMD and Ticket in this WAD do not match. This WAD appears to be "
|
||||
"invalid.")
|
||||
|
||||
@@ -74,6 +76,7 @@ class Title:
|
||||
if self.tmd.title_id == "0000000100000001":
|
||||
self.wad.wad_type = "ib"
|
||||
# Dump the TMD and set it in the WAD.
|
||||
self.tmd.content_records = self.content.content_records
|
||||
self.wad.set_tmd_data(self.tmd.dump())
|
||||
# Dump the Ticket and set it in the WAD.
|
||||
self.wad.set_ticket_data(self.ticket.dump())
|
||||
@@ -119,7 +122,8 @@ class Title:
|
||||
|
||||
def set_title_id(self, title_id: str) -> None:
|
||||
"""
|
||||
Sets the Title ID of the title in both the TMD and Ticket.
|
||||
Sets the Title ID of the title in both the TMD and Ticket. This also re-encrypts the Title Key as the Title Key
|
||||
is used as the IV for decrypting it.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
@@ -129,9 +133,26 @@ class Title:
|
||||
if len(title_id) != 16:
|
||||
raise ValueError("Invalid Title ID! Title IDs must be 8 bytes long.")
|
||||
self.tmd.set_title_id(title_id)
|
||||
title_key_decrypted = self.ticket.get_title_key()
|
||||
self.ticket.set_title_id(title_id)
|
||||
title_key_encrypted = encrypt_title_key(title_key_decrypted, self.ticket.common_key_index, title_id)
|
||||
self.ticket.title_key_enc = title_key_encrypted
|
||||
|
||||
def get_content_by_index(self, index: id) -> bytes:
|
||||
def set_title_version(self, title_version: str | int) -> None:
|
||||
"""
|
||||
Sets the version of the title in both the TMD and Ticket.
|
||||
|
||||
Accepts either standard form (vX.X) as a string or decimal form (vXXX) as an integer.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
title_version : str, int
|
||||
The new version of the title. See description for valid formats.
|
||||
"""
|
||||
self.tmd.set_title_version(title_version)
|
||||
self.ticket.set_title_version(title_version)
|
||||
|
||||
def get_content_by_index(self, index: id, skip_hash=False) -> bytes:
|
||||
"""
|
||||
Gets an individual content from the content region based on the provided index, in decrypted form.
|
||||
|
||||
@@ -139,19 +160,18 @@ class Title:
|
||||
----------
|
||||
index : int
|
||||
The index of the content you want to get.
|
||||
skip_hash : bool, optional
|
||||
Skip the hash check and return the content regardless of its hash. Defaults to false.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bytes
|
||||
The decrypted content listed in the content record.
|
||||
"""
|
||||
# Load the Title Key from the Ticket.
|
||||
title_key = self.ticket.get_title_key()
|
||||
# Get the decrypted content and return it.
|
||||
dec_content = self.content.get_content_by_index(index, title_key)
|
||||
dec_content = self.content.get_content_by_index(index, self.ticket.get_title_key(), skip_hash)
|
||||
return dec_content
|
||||
|
||||
def get_content_by_cid(self, cid: int) -> bytes:
|
||||
def get_content_by_cid(self, cid: int, skip_hash=False) -> bytes:
|
||||
"""
|
||||
Gets an individual content from the content region based on the provided Content ID, in decrypted form.
|
||||
|
||||
@@ -159,71 +179,118 @@ class Title:
|
||||
----------
|
||||
cid : int
|
||||
The Content ID of the content you want to get. Expected to be in decimal form.
|
||||
skip_hash : bool, optional
|
||||
Skip the hash check and return the content regardless of its hash. Defaults to false.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bytes
|
||||
The decrypted content listed in the content record.
|
||||
"""
|
||||
# Load the Title Key from the Ticket.
|
||||
title_key = self.ticket.get_title_key()
|
||||
# Get the decrypted content and return it.
|
||||
dec_content = self.content.get_content_by_cid(cid, title_key)
|
||||
dec_content = self.content.get_content_by_cid(cid, self.ticket.get_title_key(), skip_hash)
|
||||
return dec_content
|
||||
|
||||
def set_enc_content(self, enc_content: bytes, cid: int, index: int, content_type: int, content_size: int,
|
||||
content_hash: bytes) -> None:
|
||||
def get_title_size(self) -> int:
|
||||
"""
|
||||
Sets the provided index to a new content with the provided Content ID. Hashes and size of the content are
|
||||
set in the content record, with a new record being added if necessary. The TMD is also updated to match the new
|
||||
records.
|
||||
Gets the installed size of the title, including the TMD and Ticket, in bytes.
|
||||
|
||||
Returns
|
||||
-------
|
||||
int
|
||||
The installed size of the title, in bytes.
|
||||
"""
|
||||
title_size = 0
|
||||
# Dumping and measuring the TMD and Ticket this way to ensure that any changes to them are measured properly.
|
||||
# Yes, the Ticket size should be a constant, but it's still good to check just in case.
|
||||
title_size += len(self.tmd.dump())
|
||||
title_size += len(self.ticket.dump())
|
||||
# For contents, get their sizes from the content records, because they store the intended sizes of the decrypted
|
||||
# contents, which are usually different from the encrypted sizes.
|
||||
for record in self.content.content_records:
|
||||
title_size += record.content_size
|
||||
return title_size
|
||||
|
||||
def get_title_size_blocks(self) -> int:
|
||||
"""
|
||||
Gets the installed size of the title, including the TMD and Ticket, in the Wii's displayed "blocks" format.
|
||||
|
||||
1 Wii block is equal to 128KiB, and if any amount of a block is used, the entire block is considered used.
|
||||
|
||||
Returns
|
||||
-------
|
||||
int
|
||||
The installed size of the title, in blocks.
|
||||
"""
|
||||
title_size_bytes = self.get_title_size()
|
||||
blocks = math.ceil(title_size_bytes / 131072)
|
||||
return blocks
|
||||
|
||||
def set_enc_content(self, enc_content: bytes, index: int, content_size: int, content_hash: bytes, cid: int = None,
|
||||
content_type: int = None) -> None:
|
||||
"""
|
||||
Sets the content at the provided content index to the provided new encrypted content. The provided hash and
|
||||
content size are set in the corresponding content record. A new Content ID or content type can also be
|
||||
specified, but if it isn't than the current values are preserved.
|
||||
|
||||
This uses the content index, which is the value tied to each content and used as the IV for encryption, rather
|
||||
than the literal index in the array of content, because sometimes the contents end up out of order in a WAD
|
||||
while still retaining the original indices.
|
||||
|
||||
This also updates the content records in the TMD after the content is set.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
enc_content : bytes
|
||||
The new encrypted content to set.
|
||||
cid : int
|
||||
The Content ID to assign the new content in the content record.
|
||||
index : int
|
||||
The index to place the new content at.
|
||||
content_type : int
|
||||
The type of the new content.
|
||||
content_size : int
|
||||
The size of the new encrypted content when decrypted.
|
||||
content_hash : bytes
|
||||
The hash of the new encrypted content when decrypted.
|
||||
cid : int
|
||||
The Content ID to assign the new content in the content record.
|
||||
content_type : int
|
||||
The type of the new content.
|
||||
"""
|
||||
# Set the encrypted content.
|
||||
self.content.set_enc_content(enc_content, cid, index, content_type, content_size, content_hash)
|
||||
self.content.set_enc_content(enc_content, index, content_size, content_hash, cid, content_type)
|
||||
# Update the TMD to match.
|
||||
self.tmd.content_records = self.content.content_records
|
||||
|
||||
def set_content(self, dec_content: bytes, cid: int, index: int, content_type: int) -> None:
|
||||
def set_content(self, dec_content: bytes, index: int, cid: int = None, content_type: int = None) -> None:
|
||||
"""
|
||||
Sets the provided index to a new content with the provided Content ID. Hashes and size of the content are
|
||||
set in the content record, with a new record being added if necessary. The Title Key is sourced from this
|
||||
title's loaded ticket. The TMD is also updated to match the new records.
|
||||
Sets the content at the provided content index to the provided new decrypted content. The hash and content size
|
||||
of this content will be generated and then set in the corresponding content record. A new Content ID or content
|
||||
type can also be specified, but if it isn't than the current values are preserved.
|
||||
|
||||
This also updates the content records in the TMD after the content is set.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
dec_content : bytes
|
||||
The new decrypted content to set.
|
||||
cid : int
|
||||
The Content ID to assign the new content in the content record.
|
||||
index : int
|
||||
The index to place the new content at.
|
||||
content_type : int
|
||||
cid : int, optional
|
||||
The Content ID to assign the new content in the content record.
|
||||
content_type : int, optional
|
||||
The type of the new content.
|
||||
"""
|
||||
# Set the decrypted content.
|
||||
self.content.set_content(dec_content, cid, index, content_type, self.ticket.get_title_key())
|
||||
self.content.set_content(dec_content, index, self.ticket.get_title_key(), cid, content_type)
|
||||
# Update the TMD to match.
|
||||
self.tmd.content_records = self.content.content_records
|
||||
|
||||
def load_content(self, dec_content: bytes, index: int) -> None:
|
||||
"""
|
||||
Loads the provided decrypted content into the content region at the specified index, but first checks to make
|
||||
sure it matches the record at that index before loading. This content will be encrypted when loaded.
|
||||
Loads the provided decrypted content into the ContentRegion at the specified index, but first checks to make
|
||||
sure that it matches the corresponding record. This content will then be encrypted using the title's Title Key
|
||||
before being loaded.
|
||||
|
||||
This uses the content index, which is the value tied to each content and used as the IV for encryption, rather
|
||||
than the literal index in the array of content, because sometimes the contents end up out of order in a WAD
|
||||
while still retaining the original indices.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
@@ -234,3 +301,37 @@ class Title:
|
||||
"""
|
||||
# Load the decrypted content.
|
||||
self.content.load_content(dec_content, index, self.ticket.get_title_key())
|
||||
|
||||
def fakesign(self) -> None:
|
||||
"""
|
||||
Fakesigns this Title for the trucha bug.
|
||||
|
||||
This is done by brute-forcing a TMD and Ticket body hash starting with 00, causing it to pass signature
|
||||
verification on older IOS versions that incorrectly check the hash using strcmp() instead of memcmp(). The TMD
|
||||
and Ticket signatures will also be erased and replaced with all NULL bytes.
|
||||
|
||||
This modifies the TMD and Ticket objects that are part of this Title in place. You will need to call this method
|
||||
after any changes to the TMD or Ticket, and before dumping the Title object into a WAD to ensure that the WAD
|
||||
is properly fakesigned.
|
||||
"""
|
||||
self.tmd.fakesign()
|
||||
self.ticket.fakesign()
|
||||
|
||||
def get_is_fakesigned(self):
|
||||
"""
|
||||
Checks the Title object to see if it is currently fakesigned. This ensures that both the TMD and Ticket are
|
||||
fakesigned. For a description of fakesigning, refer to the fakesign() method.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bool:
|
||||
True if the Title is fakesigned, False otherwise.
|
||||
|
||||
See Also
|
||||
--------
|
||||
libWiiPy.title.title.Title.fakesign()
|
||||
"""
|
||||
if self.tmd.get_is_fakesigned and self.ticket.get_is_fakesigned():
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
@@ -5,9 +5,13 @@
|
||||
|
||||
import io
|
||||
import binascii
|
||||
import hashlib
|
||||
import struct
|
||||
from typing import List
|
||||
from enum import IntEnum
|
||||
from ..types import _ContentRecord
|
||||
from ..shared import _bitmask
|
||||
from .util import title_ver_dec_to_standard, title_ver_standard_to_dec
|
||||
|
||||
|
||||
class TMD:
|
||||
@@ -41,17 +45,19 @@ class TMD:
|
||||
self.ios_tid: str = "" # The Title ID of the IOS version the associated title runs on.
|
||||
self.ios_version: int = 0 # The IOS version the associated title runs on.
|
||||
self.title_id: str = "" # The Title ID of the associated title.
|
||||
self.content_type: str = "" # The type of content contained within the associated title.
|
||||
self.title_type: str = "" # The type of the associated title.
|
||||
self.group_id: int = 0 # The ID of the publisher of the associated title.
|
||||
self.region: int = 0 # The ID of the region of the associated title.
|
||||
self.ratings: bytes = b'' # The parental controls rating of the associated title.
|
||||
self.reserved1: bytes = b'' # Unknown data labeled "Reserved" on WiiBrew.
|
||||
self.ipc_mask: bytes = b''
|
||||
self.reserved2: bytes = b'' # Other "Reserved" data from WiiBrew.
|
||||
self.access_rights: bytes = b''
|
||||
self.access_rights: int = 0
|
||||
self.title_version: int = 0 # The version of the associated title.
|
||||
self.title_version_converted: int = 0 # The title version in vX.X format.
|
||||
self.num_contents: int = 0 # The number of contents contained in the associated title.
|
||||
self.boot_index: int = 0 # The content index that contains the bootable executable.
|
||||
self.minor_version: int = 0 # Minor version (unused typically).
|
||||
self.content_records: List[_ContentRecord] = []
|
||||
|
||||
def load(self, tmd: bytes) -> None:
|
||||
@@ -105,7 +111,7 @@ class TMD:
|
||||
tmd_data.seek(0x194)
|
||||
content_type_bin = tmd_data.read(4)
|
||||
content_type_hex = binascii.hexlify(content_type_bin)
|
||||
self.content_type = str(content_type_hex.decode())
|
||||
self.title_type = str(content_type_hex.decode())
|
||||
# Publisher of the title.
|
||||
tmd_data.seek(0x198)
|
||||
self.group_id = int.from_bytes(tmd_data.read(2))
|
||||
@@ -125,21 +131,23 @@ class TMD:
|
||||
# "Reserved" data 2.
|
||||
tmd_data.seek(0x1C6)
|
||||
self.reserved2 = tmd_data.read(18)
|
||||
# Access rights of the title; DVD-video access and AHBPROT.
|
||||
# Access rights of the title; DVD-video and AHB access.
|
||||
tmd_data.seek(0x1D8)
|
||||
self.access_rights = tmd_data.read(4)
|
||||
# Calculate the version number by multiplying 0x1DC by 256 and adding 0x1DD.
|
||||
self.access_rights = int.from_bytes(tmd_data.read(4))
|
||||
# Version number straight from the TMD.
|
||||
tmd_data.seek(0x1DC)
|
||||
title_version_high = int.from_bytes(tmd_data.read(1)) * 256
|
||||
tmd_data.seek(0x1DD)
|
||||
title_version_low = int.from_bytes(tmd_data.read(1))
|
||||
self.title_version = title_version_high + title_version_low
|
||||
self.title_version = int.from_bytes(tmd_data.read(2))
|
||||
# Calculate the converted version number via util module.
|
||||
self.title_version_converted = title_ver_dec_to_standard(self.title_version, self.title_id, bool(self.vwii))
|
||||
# The number of contents listed in the TMD.
|
||||
tmd_data.seek(0x1DE)
|
||||
self.num_contents = int.from_bytes(tmd_data.read(2))
|
||||
# The content index that contains the bootable executable.
|
||||
tmd_data.seek(0x1E0)
|
||||
self.boot_index = int.from_bytes(tmd_data.read(2))
|
||||
# The minor version of the title (typically unused).
|
||||
tmd_data.seek(0x1E2)
|
||||
self.minor_version = int.from_bytes(tmd_data.read(2))
|
||||
# Get content records for the number of contents in num_contents.
|
||||
self.content_records = []
|
||||
for content in range(0, self.num_contents):
|
||||
@@ -152,8 +160,7 @@ class TMD:
|
||||
|
||||
def dump(self) -> bytes:
|
||||
"""
|
||||
Dumps the TMD object back into bytes. This also sets the raw TMD attribute of TMD object to the dumped data,
|
||||
and triggers load() again to ensure that the raw data and object match.
|
||||
Dumps the TMD object back into bytes.
|
||||
|
||||
Returns
|
||||
-------
|
||||
@@ -182,7 +189,7 @@ class TMD:
|
||||
# Title's Title ID.
|
||||
tmd_data += binascii.unhexlify(self.title_id)
|
||||
# Content type.
|
||||
tmd_data += binascii.unhexlify(self.content_type)
|
||||
tmd_data += binascii.unhexlify(self.title_type)
|
||||
# Group ID.
|
||||
tmd_data += int.to_bytes(self.group_id, 2)
|
||||
# 2 bytes of zero for reasons.
|
||||
@@ -198,18 +205,15 @@ class TMD:
|
||||
# "Reserved" 2.
|
||||
tmd_data += self.reserved2
|
||||
# Access rights.
|
||||
tmd_data += self.access_rights
|
||||
tmd_data += int.to_bytes(self.access_rights, 4)
|
||||
# Title version.
|
||||
title_version_high = round(self.title_version / 256)
|
||||
tmd_data += int.to_bytes(title_version_high, 1)
|
||||
title_version_low = self.title_version % 256
|
||||
tmd_data += int.to_bytes(title_version_low, 1)
|
||||
tmd_data += int.to_bytes(self.title_version, 2)
|
||||
# Number of contents.
|
||||
tmd_data += int.to_bytes(self.num_contents, 2)
|
||||
# Boot index.
|
||||
tmd_data += int.to_bytes(self.boot_index, 2)
|
||||
# Minor version. Unused so write \x00.
|
||||
tmd_data += b'\x00\x00'
|
||||
# Minor version.
|
||||
tmd_data += int.to_bytes(self.minor_version, 2)
|
||||
# Iterate over content records, write them back into raw data, then add them to the TMD.
|
||||
for content_record in range(self.num_contents):
|
||||
content_data = b''
|
||||
@@ -223,12 +227,63 @@ class TMD:
|
||||
tmd_data += content_data
|
||||
return tmd_data
|
||||
|
||||
def fakesign(self) -> None:
|
||||
"""
|
||||
Fakesigns this TMD for the trucha bug.
|
||||
|
||||
This is done by brute-forcing a TMD body hash starting with 00, causing it to pass signature verification on
|
||||
older IOS versions that incorrectly check the hash using strcmp() instead of memcmp(). The signature will also
|
||||
be erased and replaced with all NULL bytes.
|
||||
|
||||
The hash is brute-forced by incrementing an unused 16-bit integer in the TMD by 1 until an appropriate hash is
|
||||
found.
|
||||
|
||||
This modifies the TMD object in place. You will need to call this method after any changes, and before dumping
|
||||
the TMD object back into bytes.
|
||||
"""
|
||||
# Clear the signature, so that the hash derived from it is guaranteed to always be
|
||||
# '0000000000000000000000000000000000000000'.
|
||||
self.signature = b'\x00' * 256
|
||||
current_int = 0
|
||||
test_hash = ''
|
||||
while test_hash[:2] != '00':
|
||||
current_int += 1
|
||||
self.minor_version = current_int
|
||||
# Trim off the first 320 bytes, because we're only looking for the hash of the TMD's body.
|
||||
# This is a try-except because an OverflowError will be thrown if the number being used to brute-force the
|
||||
# hash gets too big, as it is only a 16-bit integer. If that happens, then fakesigning has failed.
|
||||
try:
|
||||
test_hash = hashlib.sha1(self.dump()[320:]).hexdigest()
|
||||
except OverflowError:
|
||||
raise Exception("An error occurred during fakesigning. TMD could not be fakesigned!")
|
||||
|
||||
def get_is_fakesigned(self) -> bool:
|
||||
"""
|
||||
Checks the TMD object to see if it is currently fakesigned. For a description of fakesigning, refer to the
|
||||
fakesign() method.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bool:
|
||||
True if the TMD is fakesigned, False otherwise.
|
||||
|
||||
See Also
|
||||
--------
|
||||
libWiiPy.title.tmd.TMD.fakesign()
|
||||
"""
|
||||
if self.signature != b'\x00' * 256:
|
||||
return False
|
||||
test_hash = hashlib.sha1(self.dump()[320:]).hexdigest()
|
||||
if test_hash[:2] != '00':
|
||||
return False
|
||||
return True
|
||||
|
||||
def get_title_region(self) -> str:
|
||||
"""
|
||||
Gets the region of the TMD's associated title.
|
||||
|
||||
Can be one of several possible values:
|
||||
'JAP', 'USA', 'EUR', 'WORLD', or 'KOR'.
|
||||
'Japan', 'North America', 'Europe', 'World', or 'Korea'.
|
||||
|
||||
Returns
|
||||
-------
|
||||
@@ -237,29 +292,15 @@ class TMD:
|
||||
"""
|
||||
match self.region:
|
||||
case 0:
|
||||
return "JAP"
|
||||
return "Japan"
|
||||
case 1:
|
||||
return "USA"
|
||||
return "North America"
|
||||
case 2:
|
||||
return "EUR"
|
||||
return "Europe"
|
||||
case 3:
|
||||
return "WORLD"
|
||||
return "World"
|
||||
case 4:
|
||||
return "KOR"
|
||||
|
||||
def get_is_vwii_title(self) -> bool:
|
||||
"""
|
||||
Gets whether the TMD is designed for the vWii or not.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bool
|
||||
If the title is for vWii.
|
||||
"""
|
||||
if self.vwii == 1:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
return "Korea"
|
||||
|
||||
def get_title_type(self) -> str:
|
||||
"""
|
||||
@@ -273,8 +314,7 @@ class TMD:
|
||||
str
|
||||
The type of the title.
|
||||
"""
|
||||
title_id_high = self.title_id[:8]
|
||||
match title_id_high:
|
||||
match self.title_type:
|
||||
case '00000001':
|
||||
return "System"
|
||||
case '00010000':
|
||||
@@ -292,28 +332,40 @@ class TMD:
|
||||
case _:
|
||||
return "Unknown"
|
||||
|
||||
def get_content_type(self):
|
||||
def get_content_type(self, content_index: int) -> str:
|
||||
"""
|
||||
Gets the type of content contained in the TMD's associated title.
|
||||
|
||||
Can be one of several possible values:
|
||||
'Normal', 'Development/Unknown', 'Hash Tree', 'DLC', or 'Shared'
|
||||
|
||||
Parameters
|
||||
----------
|
||||
content_index : int
|
||||
The index of the content you want the type of.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
The type of content.
|
||||
"""
|
||||
match self.content_type:
|
||||
case '00000001':
|
||||
# Get a list of the current content indices, so we can make sure the target one exists. Doing it this way
|
||||
# ensures we can find the target, even if the highest content index is greater than the highest literal index.
|
||||
current_indices = []
|
||||
for record in self.content_records:
|
||||
current_indices.append(record.index)
|
||||
# This is the literal index in the list of content that we're going to get.
|
||||
target_index = current_indices.index(content_index)
|
||||
match self.content_records[target_index].content_type:
|
||||
case 1:
|
||||
return "Normal"
|
||||
case '00000002':
|
||||
case 2:
|
||||
return "Development/Unknown"
|
||||
case '00000003':
|
||||
case 3:
|
||||
return "Hash Tree"
|
||||
case '00004001':
|
||||
case 16385:
|
||||
return "DLC"
|
||||
case '00008001':
|
||||
case 32769:
|
||||
return "Shared"
|
||||
case _:
|
||||
return "Unknown"
|
||||
@@ -338,9 +390,31 @@ class TMD:
|
||||
raise IndexError("Invalid content record! TMD lists '" + str(self.num_contents - 1) +
|
||||
"' contents but index was '" + str(record) + "'!")
|
||||
|
||||
class AccessFlags(IntEnum):
|
||||
AHB = 0
|
||||
DVD_VIDEO = 1
|
||||
|
||||
def get_access_right(self, flag: int) -> bool:
|
||||
"""
|
||||
Gets whether an access rights flag is enabled or not. This is done by checking the specified bit. Possible flags
|
||||
and their corresponding bits are defined in the AccessFlags enum.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
flag : int
|
||||
The flag to check.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bool
|
||||
True if the flag is enabled, False otherwise.
|
||||
"""
|
||||
return bool(self.access_rights & _bitmask(flag))
|
||||
|
||||
def set_title_id(self, title_id) -> None:
|
||||
"""
|
||||
Sets the Title ID of the title in the ticket.
|
||||
Sets the Title ID property of the TMD. Recommended over setting the property directly because of input
|
||||
validation.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
@@ -350,3 +424,37 @@ class TMD:
|
||||
if len(title_id) != 16:
|
||||
raise ValueError("Invalid Title ID! Title IDs must be 8 bytes long.")
|
||||
self.title_id = title_id
|
||||
|
||||
def set_title_version(self, new_version: str | int) -> None:
|
||||
"""
|
||||
Sets the version of the title in the TMD. Recommended over setting the data directly because of input
|
||||
validation.
|
||||
|
||||
Accepts either standard form (vX.X) as a string or decimal form (vXXX) as an integer.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
new_version : str, int
|
||||
The new version of the title. See description for valid formats.
|
||||
"""
|
||||
if type(new_version) is str:
|
||||
# Validate string input is in the correct format, then validate that the version isn't higher than v255.0.
|
||||
# If checks pass, set that as the converted version, then convert to decimal form and set that as well.
|
||||
version_str_split = new_version.split(".")
|
||||
if len(version_str_split) != 2:
|
||||
raise ValueError("Title version is not valid! String version must be entered in format \"X.X\".")
|
||||
if int(version_str_split[0]) > 255 or int(version_str_split[1]) > 255:
|
||||
raise ValueError("Title version is not valid! String version number cannot exceed v255.255.")
|
||||
self.title_version_converted = new_version
|
||||
version_converted = title_ver_standard_to_dec(new_version, self.title_id)
|
||||
self.title_version = version_converted
|
||||
elif type(new_version) is int:
|
||||
# Validate that the version isn't higher than v65280. If the check passes, set that as the title version,
|
||||
# then convert to standard form and set that as well.
|
||||
if new_version > 65535:
|
||||
raise ValueError("Title version is not valid! Integer version number cannot exceed v65535.")
|
||||
self.title_version = new_version
|
||||
version_converted = title_ver_dec_to_standard(new_version, self.title_id, bool(self.vwii))
|
||||
self.title_version_converted = version_converted
|
||||
else:
|
||||
raise TypeError("Title version type is not valid! Type must be either integer or string.")
|
||||
|
||||
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.
|
||||
# ====================================================================================
|
||||
wad_cert_offset = self.wad_hdr_size
|
||||
# crl isn't ever used, however an entry for its size exists in the header, so its calculated just in case.
|
||||
# crl isn't ever used, however an entry for its size exists in the header, so it's calculated just in case.
|
||||
wad_crl_offset = _align_value(wad_cert_offset + self.wad_cert_size)
|
||||
wad_tik_offset = _align_value(wad_crl_offset + self.wad_crl_size)
|
||||
wad_tmd_offset = _align_value(wad_tik_offset + self.wad_tik_size)
|
||||
wad_content_offset = _align_value(wad_tmd_offset + self.wad_tmd_size)
|
||||
# meta isn't guaranteed to be used, but some older SDK titles use it, and not reading it breaks things.
|
||||
wad_meta_offset = _align_value(wad_tmd_offset + self.wad_tmd_size)
|
||||
wad_content_offset = _align_value(wad_meta_offset + self.wad_meta_size)
|
||||
wad_meta_offset = _align_value(wad_content_offset + self.wad_content_size)
|
||||
# ====================================================================================
|
||||
# Load data for each WAD section based on the previously calculated offsets.
|
||||
# ====================================================================================
|
||||
@@ -174,12 +174,12 @@ class WAD:
|
||||
# Retrieve the TMD data and write it out.
|
||||
wad_data += self.get_tmd_data()
|
||||
wad_data = _pad_bytes(wad_data)
|
||||
# Retrieve the meta/footer data and write it out.
|
||||
wad_data += self.get_meta_data()
|
||||
wad_data = _pad_bytes(wad_data)
|
||||
# Retrieve the content data and write it out.
|
||||
wad_data += self.get_content_data()
|
||||
wad_data = _pad_bytes(wad_data)
|
||||
# Retrieve the meta/footer data and write it out.
|
||||
wad_data += self.get_meta_data()
|
||||
wad_data = _pad_bytes(wad_data)
|
||||
return wad_data
|
||||
|
||||
def get_wad_type(self) -> str:
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
|
||||
import unittest
|
||||
|
||||
from test_commonkeys import TestCommonKeys
|
||||
from .title.commonkeys_test import *
|
||||
from .title.nus_test import *
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
0
test/title/__init__.py
Normal file
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
|
||||
|
||||
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