46 Commits

Author SHA1 Message Date
4f96e1b0d9 Add more detailed keys to pyproject.toml 2024-07-17 21:03:08 -04:00
bcd61b8a37 Slightly improve fakesign docstrings 2024-07-17 20:48:16 -04:00
a56fa6e051 Added methods to fakesign a TMD or Ticket 2024-07-17 20:44:04 -04:00
535de7f228 Read/write minor version in tmd module, allows for fakesigning 2024-07-10 20:18:15 +10:00
adac67b158 Change title version handling in tmd module
Now saving the version number (like v513) straight from the TMD and using that to dump the TMD, in case the converted version number (like v2.2) doesn't work right, which mostly applies to the system menu.
2024-07-10 08:11:14 +10:00
f96da98d23 Separate out components of a TMD signature
Not sure why this hadn't been done already given that the ticket module has always used separate properties for the signature type and data.
2024-07-06 20:15:18 +10:00
c5abf16f67 Finally fix content region size in WAD headers 2024-07-06 20:00:18 +10:00
45638769a9 Allow for extracting a U8 archive to an existing (empty) directory 2024-07-05 08:34:23 +10:00
1f731bbc81 Improve WAD handling, fixes IOS WADs made with other tools not extracting
The way content sizes are handled has been adjusted to allow IOS WADs (which have their content structured a bit differently) made via other tools to be extracted.
Writing out WADs has also been changed so that the content size in the header now matches the output of older tools.
2024-07-02 18:29:15 +10:00
9bfb44771e Merge remote-tracking branch 'origin/main' 2024-06-26 17:49:24 -04:00
82f4e7dcc4 Wrote new guide on extracting data from a WAD 2024-06-26 17:49:10 -04:00
c4847365ab Replaced README banner at the Python Software Foundation's request
libWiiPy's previous banner used the Python logo in a manner deemed unacceptable by the PSF, and they have requested that its use be discontinued immediately.
2024-06-26 11:10:09 -04:00
e00fc376c6 Replaced banner at the Python Software Foundation's request 2024-06-26 11:08:19 -04:00
04fa9034a0 Added new plugins, began writing tutorials 2024-06-26 01:15:43 -04:00
df1ed559ef Rewrote all docs in Markdown instead of the default reST 2024-06-25 22:57:09 -04:00
53e098ca9b Make sure libWiiPy is built and installed so Sphinx can import it 2024-06-25 21:38:33 -04:00
da2d6b0003 Added workflow to build and publish docs on push 2024-06-25 17:42:28 -04:00
6575dd37f7 Added basics for a proper API docs website 2024-06-25 17:34:34 -04:00
75510ed2b9 Updated README to reflect changes being worked on for v0.4.0 2024-06-24 23:19:16 -04:00
53f798e36a Merge pull request #20 from NinjaCheetah/ash-dec
Add support for decompressing ASH files
2024-06-23 18:37:06 -04:00
0861c20100 Privatized many functions and classes that should be private
Also changed some imports to import as _name so that stuff like dataclass() doesn't appear as available under libWiiPy.title
2024-06-23 18:28:32 -04:00
2d64f7961e Added new ash module to archive subpackage, can decompress ASH files 2024-06-23 17:59:12 -04:00
7b7a3fbf51 Return FileNotFoundError if path doesn't exist in pack_u8() 2024-06-12 14:11:57 -04:00
6b8eda7823 Also automatically import crypto for the title subpackage 2024-06-07 23:36:20 -04:00
af6977a23c Fix imports for new package structure 2024-06-07 23:30:02 -04:00
96c975728b Merge branch 'refs/heads/u8' 2024-06-07 23:22:08 -04:00
2fbb2b04f3 Fix nus module not being imported automatically 2024-06-07 23:21:03 -04:00
195a005752 Merge pull request #19 from NinjaCheetah/u8
Add U8 packing and extracting
2024-06-07 23:12:50 -04:00
736a9e5c0c Update README for v0.3.0 release 2024-06-07 23:11:51 -04:00
5743ee2695 Remove some pointless comments I noticed 2024-06-07 23:05:45 -04:00
b30017460b Completed the U8 module, can now handle full folder packing 2024-06-07 22:30:34 -04:00
2755364472 Rewrote U8 extraction code entirely, now handles U8 files in all cases 2024-06-05 22:02:19 -04:00
1d77868cb1 Reworked U8 module and added initial support for dumping U8 archives back to bytes 2024-06-03 23:20:34 -04:00
ade4b68394 Entirely restructured package to make it more of a proper Python package
THIS IS A BREAKING COMMIT! ALL v0.2.X BASED PROJECTS WILL NEED TO BE UPDATED TO SUPPORT v0.3.X!
2024-06-02 22:18:05 -04:00
bc9224e40b Added base for tests 2024-05-29 20:05:41 -04:00
cbaafca0d1 Working basic code to extract a U8 archive 2024-05-29 12:48:44 -04:00
ede33dc503 Add highly experimental U8 handling module 2024-05-16 21:24:42 -04:00
6a81722ec5 Read/write reserved data in tmd.py, since it mattered for the DSi, it may matter here 2024-05-09 11:19:29 -04:00
ecc68d9e57 Updated definitions of TMD properties based on new information 2024-05-09 11:11:54 -04:00
c42dc66209 Update README.md 2024-05-07 21:35:22 -04:00
045613216a Replaced unnecessary BytesIO usages with standard variables 2024-05-06 19:34:18 -04:00
98666285db Improved comments, moved TID to IV conversion into a function in shared.py 2024-05-03 22:32:51 -04:00
ba320a29de Improved IV handling code for Title Keys in crypto.py 2024-05-03 17:27:52 -04:00
9890a6dbac Added function to crypto.py for encrypting a title key 2024-05-03 15:10:17 -04:00
c92a8096ea Rename fallback_endpoint to wiiu_endpoint in nus.py 2024-05-02 22:20:59 -04:00
99a55a3de5 Added the ability to use the Wii U NUS servers to nus.py 2024-05-01 22:22:30 -04:00
38 changed files with 1746 additions and 364 deletions

64
.github/workflows/sphinx-docs.yml vendored Normal file
View File

@@ -0,0 +1,64 @@
# Workflow to build libWiiPy documentation with Sphinx and then publish it
name: Build and publish documentation with Sphinx
on:
# Runs on pushes targeting the default branch
push:
branches: ["main"]
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
permissions:
contents: read
pages: write
id-token: write
# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
concurrency:
group: "pages"
cancel-in-progress: false
jobs:
# Build job
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Pages
uses: actions/configure-pages@v5
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install Dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Build and Install libWiiPy for Sphinx
run: |
python -m build
pip install dist/libwiipy*.tar.gz
- name: Build Documentation with Sphinx
run: |
python -m sphinx -M html docs/source/ docs/build/
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
# Upload only the build/html directory
path: 'docs/build/html'
# Deployment job
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
needs: build
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

4
.gitignore vendored
View File

@@ -161,9 +161,11 @@ cython_debug/
# option (not recommended) you can uncomment the following to ignore the entire idea folder. # option (not recommended) you can uncomment the following to ignore the entire idea folder.
.idea/ .idea/
# Allows me to keep TMD files in my repository folder for testing without accidentally publishing them # Relevant files that are used for testing libWiiPy's features.
*.tmd *.tmd
*.wad *.wad
*.arc
*.ash
out_prod/ out_prod/
remakewad.pl remakewad.pl

View File

@@ -1,16 +1,18 @@
![libWiiPy](https://github.com/NinjaCheetah/libWiiPy/assets/58050615/80093c68-b86e-4b96-87b7-db3855382ca8) ![banner](https://github.com/NinjaCheetah/libWiiPy/assets/58050615/00ea4c41-673c-4a74-addb-fbb40b4313c8)
# libWiiPy # libWiiPy
libWiiPy is a modern Python 3 library for interacting with and editing files from 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 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 inspired by [libWiiSharp](https://github.com/TheShadowEevee/libWiiSharp), originally created by `Leathl`, now maintained by [@TheShadowEevee](https://github.com/TheShadowEevee). libWiiSharp is absolutely the way to go if you need a C# library for Wii files. 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!
**Note:** While libWiiPy is directly inspired by libWiiSharp and aims to have feature parity with it, no code from either libWiiSharp or Wii.py was used in the making of this library. All code is original and is written by [@NinjaCheetah](https://github.com/NinjaCheetah), [@rvtr](https://github.com/rvtr), and any other GitHub contributors.
# Features # Features
This list will expand as libWiiPy is developed, but these features are currently available: This list will expand as libWiiPy is developed, but these features are currently available:
- TMD, ticket, and WAD parsing - TMD and Ticket parsing (`.tmd`, `.tik`)
- WAD content extraction, decryption, re-encryption, and packing - Title content decryption, re-encryption
- Downloading free titles from the NUS - Packing and unpacking WAD files (`.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)
# Usage # Usage
A wiki, and in the future a potential documenation site, is being worked on, and can be accessed [here](https://github.com/NinjaCheetah/libWiiPy/wiki). It is currently fairly barebones, but it will be improved in the future. 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.
@@ -31,27 +33,27 @@ Please be aware that because libWiiPy is in a very early state right now, many f
To build this package locally, the steps are quite simple, and should apply to all platforms. Make sure you've set up your `venv` first! To build this package locally, the steps are quite simple, and should apply to all platforms. Make sure you've set up your `venv` first!
First, install the dependencies from `requirements.txt`: First, install the dependencies from `requirements.txt`:
```py ```sh
pip install -r requirements.txt pip install -r requirements.txt
``` ```
Then, build the package using the Python `build` module: Then, build the package using the Python `build` module:
```py ```sh
python -m build python -m build
``` ```
And that's all! You'll find your compiled pip package in `dist/`. And that's all! You'll find your compiled pip package in `dist/`.
# Special Thanks # Special Thanks
This project wouldn't be possible without the amazing people behind its predecessors and all of the people who have contributed to the documentation of the Wii's inner workings over at [Wiibrew](https://wiibrew.org). This project wouldn't be possible without the amazing people behind its predecessors and all of the people who have contributed to the documentation of the Wii's inner workings over at [WiiBrew](https://wiibrew.org).
## Special Thanks for the Inspiration and Previous Projects ## Special Thanks to People Behind Related Projects
- Xuzz, SquidMan, megazig, Matt_P, Omega and The Lemon Man for creating Wii.py - Xuzz, SquidMan, megazig, Matt_P, Omega and The Lemon Man for creating Wii.py
- Leathl for creating libWiiSharp - Leathl for creating libWiiSharp
- TheShadowEevee for maintaining libWiiSharp - TheShadowEevee for maintaining libWiiSharp
## Special Thanks to Wiibrew Contributors ## Special Thanks to WiiBrew Contributors
Thank you to all of the contributors to the documentation on the Wiibrew pages that make this all understandable! Some of the key articles referenced are as follows: Thank you to all of the contributors to the documentation on the WiiBrew pages that make this all understandable! Some of the key articles referenced are as follows:
- [Title metadata](https://wiibrew.org/wiki/Title_metadata), for the documentation on how a TMD is structured - [Title metadata](https://wiibrew.org/wiki/Title_metadata), for the documentation on how a TMD is structured
- [WAD files](https://wiibrew.org/wiki/WAD_files), for the documentation on how a WAD is structured - [WAD files](https://wiibrew.org/wiki/WAD_files), for the documentation on how a WAD is structured
- [IOS history](https://wiibrew.org/wiki/IOS_history), for the documentation on IOS TIDs and how IOS is versioned - [IOS history](https://wiibrew.org/wiki/IOS_history), for the documentation on IOS TIDs and how IOS is versioned
@@ -59,3 +61,5 @@ Thank you to all of the contributors to the documentation on the Wiibrew pages t
### One additional special thanks to [@DamiDoop](https://github.com/DamiDoop)! ### One additional special thanks to [@DamiDoop](https://github.com/DamiDoop)!
She made the very cool banner you can see at the top of this README, and has also helped greatly with my sanity throughout debugging this library. She made the very cool banner you can see at the top of this README, and has also helped greatly with my sanity throughout debugging this library.
**Note:** While libWiiPy is directly inspired by libWiiSharp and aims to have feature parity with it, no code from either libWiiSharp or Wii.py was used in the making of this library. All code is original and is written by [@NinjaCheetah](https://github.com/NinjaCheetah), [@rvtr](https://github.com/rvtr), and any other GitHub contributors.

20
docs/Makefile Normal file
View File

@@ -0,0 +1,20 @@
# Minimal makefile for Sphinx documentation
#
# You can set these variables from the command line, and also
# from the environment for the first two.
SPHINXOPTS ?=
SPHINXBUILD ?= sphinx-build
SOURCEDIR = source
BUILDDIR = build
# Put it first so that "make" without argument is like "make help".
help:
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
.PHONY: help Makefile
# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)

35
docs/make.bat Normal file
View File

@@ -0,0 +1,35 @@
@ECHO OFF
pushd %~dp0
REM Command file for Sphinx documentation
if "%SPHINXBUILD%" == "" (
set SPHINXBUILD=sphinx-build
)
set SOURCEDIR=source
set BUILDDIR=build
%SPHINXBUILD% >NUL 2>NUL
if errorlevel 9009 (
echo.
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
echo.installed, then set the SPHINXBUILD environment variable to point
echo.to the full path of the 'sphinx-build' executable. Alternatively you
echo.may add the Sphinx directory to PATH.
echo.
echo.If you don't have Sphinx installed, grab it from
echo.https://www.sphinx-doc.org/
exit /b 1
)
if "%1" == "" goto help
%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
goto end
:help
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
:end
popd

BIN
docs/source/banner.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

38
docs/source/conf.py Normal file
View File

@@ -0,0 +1,38 @@
# Configuration file for the Sphinx documentation builder.
#
# For the full list of built-in configuration values, see the documentation:
# https://www.sphinx-doc.org/en/master/usage/configuration.html
from datetime import date
# -- Project information -----------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
project = 'libWiiPy'
copyright = f'{date.today().year}, NinjaCheetah & Contributors'
author = 'NinjaCheetah & Contributors'
release = 'main'
# -- General configuration ---------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
extensions = ['myst_parser', 'sphinx.ext.napoleon', 'sphinx_copybutton', 'sphinx_tippy', 'sphinx_design']
templates_path = ['_templates']
exclude_patterns = ["Thumbs.db", ".DS_Store"]
# -- Options for HTML output -------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
html_theme = 'sphinx_book_theme'
html_static_path = ['_static']
html_logo = "banner.png"
html_title = "libWiiPy API Docs"
html_theme_options = {
"repository_url": "https://github.com/NinjaCheetah/libWiiPy",
"use_repository_button": True
}
# MyST Configuration
myst_enable_extensions = ['colon_fence', 'deflist']

46
docs/source/index.md Normal file
View File

@@ -0,0 +1,46 @@
---
sd_hide_title: true
---
# Overview
# libWiiPy API Docs
Welcome to the API documentation website for libWiiPy! libWiiPy is a modern Python 3 library for handling the various files and formats found on the Wii.
```{toctree}
:hidden:
self
```
```{toctree}
:hidden:
:caption: The Basics
usage/installation.md
usage/getting-started.md
```
```{toctree}
:hidden:
:caption: Working with Titles
titles/title-anatomy.md
titles/extracting-titles.md
titles/title-module.md
titles/nus-downloading.md
```
```{toctree}
:hidden:
:caption: Other Useful Pages
modules.md
```
## Indices and tables
* [Full Index](<project:#genindex>)
* [Module Index](<project:#modules>)
* <project:#search>

View File

@@ -0,0 +1,28 @@
# libWiiPy.archive package
## Submodules
### libWiiPy.archive.ash module
```{eval-rst}
.. automodule:: libWiiPy.archive.ash
:members:
:undoc-members:
:show-inheritance:
```
### libWiiPy.archive.u8 module
```{eval-rst}
.. automodule:: libWiiPy.archive.u8
:members:
:undoc-members:
:show-inheritance:
```
## Module contents
```{eval-rst}
.. automodule:: libWiiPy.archive
:members:
:undoc-members:
:show-inheritance:
```

34
docs/source/libWiiPy.md Normal file
View File

@@ -0,0 +1,34 @@
# libWiiPy package
## Subpackages
```{toctree}
:maxdepth: 4
libWiiPy.archive
libWiiPy.title
```
## Submodules
### libWiiPy.shared module
libWiiPy's ``shared`` module is private and contains only private functions used by other modules.
```{eval-rst}
.. automodule:: libWiiPy.shared
:members:
:undoc-members:
:show-inheritance:
```
### libWiiPy.types module
libWiiPy's ``types`` module is private and contains only private classes used by other modules.
```{eval-rst}
.. automodule:: libWiiPy.types
:members:
:undoc-members:
:show-inheritance:
```

View File

@@ -0,0 +1,78 @@
# libWiiPy.title package
## Submodules
### libWiiPy.title.commonkeys module
```{eval-rst}
.. automodule:: libWiiPy.title.commonkeys
:members:
:undoc-members:
:show-inheritance:
```
### libWiiPy.title.content module
```{eval-rst}
.. automodule:: libWiiPy.title.content
:members:
:undoc-members:
:show-inheritance:
```
### libWiiPy.title.crypto module
```{eval-rst}
.. automodule:: libWiiPy.title.crypto
:members:
:undoc-members:
:show-inheritance:
```
### libWiiPy.title.nus module
```{eval-rst}
.. automodule:: libWiiPy.title.nus
:members:
:undoc-members:
:show-inheritance:
```
### libWiiPy.title.ticket module
```{eval-rst}
.. automodule:: libWiiPy.title.ticket
:members:
:undoc-members:
:show-inheritance:
```
### libWiiPy.title.title module
```{eval-rst}
.. automodule:: libWiiPy.title.title
:members:
:undoc-members:
:show-inheritance:
```
### libWiiPy.title.tmd module
```{eval-rst}
.. automodule:: libWiiPy.title.tmd
:members:
:undoc-members:
:show-inheritance:
```
### libWiiPy.title.wad module
```{eval-rst}
.. automodule:: libWiiPy.title.wad
:members:
:undoc-members:
:show-inheritance:
```
## Module contents
```{eval-rst}
.. automodule:: libWiiPy.title
:members:
:undoc-members:
:show-inheritance:
```

7
docs/source/modules.md Normal file
View File

@@ -0,0 +1,7 @@
# Modules Overview
```{toctree}
:maxdepth: 4
libWiiPy
```

View File

@@ -0,0 +1,131 @@
# Extracting Titles from WAD Files
One of the most common uses for libWiiPy's title subpackage is extracting WAD files so that you can edit their contents. This can open up the doors to modding, like with the [famous DVD image](https://ncxprogramming.com/2023/06/19/wii-dvd-p3.html) in the Wii Menu that actually kicked this project off, or other projects like datamining.
:::{note}
This guide assumes that you already have a WAD file that you'd like to extract, and that this WAD file doesn't use a personalized ticket, as titles with personalized tickets are not as easy to manipulate. WADs like that aren't very common, as most WADs created from the NUS, dumped from a console, or obtained via other methods will not have this type of ticket, so if in doubt, it will probably work fine.
If you don't currently have a WAD file, you may want to skip ahead to <project:#/titles/nus-downloading> first to obtain one for a free title first.
:::
:::{hint}
If you've gotten here, but you're just looking for a tool to do all of this rather than a guide on how to write your own code, you're probably looking for something like [WiiPy](https://github.com/NinjaCheetah/WiiPy). WiiPy is a command line tool that covers all of libWiiPy's features, and is also made by NinjaCheetah.
:::
With all of that out of the way, let's begin!
## Loading the WAD
The first thing we'll do is import libWiiPy and load up our file:
```pycon
>>> import libWiiPy
>>> wad_data = open("file.wad").read()
>>>
```
Then, we can create a new WAD object, and load our data into it:
```pycon
>>> wad = libWiiPy.title.WAD()
>>> wad.load(wad_data)
>>>
```
And viola! We have a WAD object that we can use to get each separate part of our title.
## Picking the WAD Apart
Now that we have our WAD loaded, we need to separate it out into its components. On top of the parts we already established, a WAD also contains a certificate, checked by IOS during official title installations to ensure that a title was signed by Nintendo, and potentially two more areas called the footer and the CRL. Footers aren't a necessary part of a WAD, and when they do exist, they typically only contain the build timestamp and the machine it was built on. CRLs are even less common, and have never actually been found inside any WAD, but we know they exist because of things we've seen that Nintendo would really rather we hadn't. Because these three components don't have data we can edit, they're only ever represented as bytes, and do not have their own classes.
### The TMD
To get the TMD, let's create a new TMD object, and then use the method `get_tmd_data()` on our WAD object as the source for our TMD data:
```pycon
>>> tmd = libWiiPy.title.TMD()
>>> tmd.load(wad.get_tmd_data())
>>>
```
And now, just like in our <project:#/usage/getting-started> tutorial, we have a TMD object, and can get all the same data from it!
### The Ticket
Next up, we need to get the Ticket. The process for getting the Ticket is very similar to getting the TMD. We'll create a new Ticket object, and then use the method `get_ticket_data()` to get the data:
```pycon
>>> ticket = libWiiPy.title.Ticket()
>>> ticket.load(wad.get_ticket_data())
>>>
```
Similarly to the TMD, we can use this Ticket object to get all the properties of a Ticket. This includes getting the decrypted version of the Ticket's encrypted Title Key. In fact, why don't we do that know?
We can use a Ticket's `get_title_key()` method to decrypt the Title Key and return it. This uses the Ticket's `title_key_enc`, `common_key_index`, and `title_id` properties to get the IV and common key required to decrypt the Title Key.
```pycon
>>> title_key = ticket.get_title_key()
>>>
```
:::{danger}
If the Ticket contained in your WAD is personalized, this Title Key will be invalid! `get_title_key()` won't return any error, as it has no way of validating the output, but the key will not work to decrypt any content.
:::
### The Contents
Now that we have our TMD and Ticket extracted, we can get to work on extracting and decrypting the content.
First, we'll need to create a new ContentRegion object, which requires sourcing the raw data of all the WAD's contents (which are stored as one continuous block) using `get_content_data()`, as well as the content records found in our TMD object. We can do this like so:
```pycon
>>> content_region = libWiiPy.title.ContentRegion()
>>> content_region.load(wad.get_content_data(), tmd.content_records)
>>>
```
The content records from the TMD are used by the `content` module to parse the block of data that the contents are stored in so that they can be separated back out into individual files. Speaking of which, let's try extracting one (still in its encrypted form, for now) just to make sure everything is working. For this example, we'll use `get_enc_content_by_index()`, and get the content at index 0:
```pycon
>>> encrypted_content = content_region.get_enc_content_by_index(0)
>>>
```
As long as that's all good, that means our WAD's content has successfully been parsed, and we can start decrypting it!
Let's try getting the same content again, the one at index 0, but this time in its decrypted form. We can use the method `get_content_by_index()` for this, which takes the index of the content we want, and the Title Key that we saved in the last step.
```pycon
>>> decrypted_content = content_region.get_content_by_index(0, title_key)
>>>
```
:::{error}
If you get an error here saying that the hash of your decrypted content doesn't match the expected hash, then something has gone wrong. There are several possibilities, including your Ticket being personalized, causing you to get an invalid Title Key, your WAD having mismatched data, or your content being modified without the hash in the content record having been updated.
:::
If you don't get any errors, then congratulations! You've just extracted your first decrypted content from a WAD!
Now that we know things are working, why don't we speed things up a little by using the content region's `get_contents()` method, which will return a list of all the decrypted content:
```pycon
>>> decrypted_content_list = content_region.get_contents(title_key)
>>>
```
And just like that, we have our TMD, Ticket, and decrypted content all extracted! From here, what you do with them is up to you and whatever program you're working on. For example, to make a simple WAD extractor, you may want to write all these files to an output directory.
### The Other Data
As mentioned earlier in this guide, WADs also contain up to three extra regions of data: the certificate, the footer, and the CRL. The procedure for extracting all of these is pretty simple, and follows the same formula as any other data in a WAD:
```pycon
>>> certificate = wad.get_cert_data()
>>> footer = wad.get_meta_data()
>>> crl = wad.get_crl_data()
>>>
```
Beyond getting their raw data, there isn't anything you can directly do with these components with libWiiPy. If one of these components doesn't exist, libWiiPy will simply return an empty bytes object.
:::{note}
Managed to find a WAD somewhere with CRL data? I'd love to here more, so feel free to email me at [ninjacheetah@ncxprogramming.com](mailto:ninjacheetah@ncxprogramming.com).
:::
<hr>
Now, that might all seem a bit complicated. What if instead there was a way to manage a title using one object that handles all the individual components for you? Well, you're in luck! On top of the fairly low-level way to extract a WAD provided in this guide, libWiiPy also offers a higher-level method through the <project:#libWiiPy.title.title> module. On the next page, we'll dive into the specifics, and how to use this module.

View File

@@ -0,0 +1,5 @@
# Downloading from the NUS
<project:#libWiiPy.title.nus>
Pardon our dust! This website is still under construction, and we haven't quite gotten to this one yet.

View File

@@ -0,0 +1,34 @@
# Anatomy of a Title
Before we start working with titles, it's important to understand what components make up a title on the Wii, and how each of those components are handled in libWiiPy. If you're here, you likely already understand what a title is in the context of the Wii, but if not, [WiiBrew](https://wiibrew.org/wiki/Main_Page) is a great reference to learn more about the Wii's software.
:::{note}
"Title" can be used to refer to both digital titles preinstalled on the Wii and distributed via the Wii Shop Channel and system updates, as well as games released on discs. libWiiPy does not currently offer methods to interact with most data found on a game disc, so for all intents and purposes, "title" in this documentation is referring to digital titles only unless otherwise specified.
:::
There are three major components of a title: the **TMD**, the **Ticket**, and the **contents**. A brief summary of each is provided below.
## TMD
<project:#libWiiPy.title.tmd>
A **TMD** (**T**itle **M**eta**d**ata) contains basic information about a title, such as its Title ID, version, what IOS and version it's designed to run under, whether it's for the vWii or not, and more related information. The TMD also stores a list of content records that specify the index and ID of each content, as well as the SHA-1 hash of the decrypted content, to ensure that decryption was successful.
In libWiiPy, a TMD is represented by a `TMD()` object, which is part of the `tmd` module in the `title` subpackge, and is imported automatically. A content record is represented by its own `ContentRecord()` object, which is a private class designed to only be used by other modules.
## Ticket
<project:#libWiiPy.title.ticket>
A **Ticket** primarily contains the encrypted Title Key for a title, as well as the information required to decrypt that key. They come in two forms: common tickets, which are freely available from the Nintendo Update Servers (NUS), and personalized tickets, which are issued to your console specifically by the Wii Shop Channel (or at least they were before it closed, excluding the free titles still available).
In libWiiPy, a Ticket is represented by a `Ticket()` object, which is part of the `ticket` module in the `title` subpackage, and is imported automatically.
## Content
<project:#libWiiPy.title.content>
**Contents** are the files in a title that contain the actual data, whether that be the main executable or resources required by it. They're usually stored encrypted in a WAD file or on the NUS, until they are decrypted during installation to a console. The Title Key stored in the Ticket is required to decrypt the contents of a title. Each content has a matching record with its index and Content ID, as well as the SHA-1 hash of its decrypted data. These records are stored in the TMD.
In libWiiPy, contents are represented by a `ContentRegion()` object, which is part of the `content` module in the `title` subpackge, and is imported automatically. A content record is represented by its own `ContentRecord()` object, which is a private class designed to only be used by other modules.
To effectively work with a whole title, you'll need to understand the basics of these three components and the libWiiPy classes that are used to represent them.
Now, let's get into how you'd use them to extract a title from a WAD file.

View File

@@ -0,0 +1,5 @@
# The Title Module
<project:#libWiiPy.title.title>
Pardon our dust! This website is still under construction, and we haven't quite gotten to this one yet.

View File

@@ -0,0 +1,42 @@
# Getting Started
Once you have libWiiPy installed, it's time to write your first code!
As an example, let's say you have a TMD file with a generic name, `title.tmd`, and because of this you need to find out some information about it, so you know what title it belongs to.
First off, let's import `libWiiPy`, and load up our file:
```pycon
>>> import libWiiPy
>>> tmd_file = open("title.tmd", "rb").read()
>>>
```
Then we'll create a new TMD object, and load our file into it:
```pycon
>>> tmd = libWiiPy.title.TMD()
>>> tmd.load(tmd_file)
>>>
```
And ta-da! We now have a new TMD object that can be used to find out whatever we need to know about this TMD.
So, to find out what title this TMD is for, let's try looking at the TMD's `title_id` property, like this:
```pycon
>>> print(tmd.title_id)
0000000100000002
>>>
```
Aha! `0000000100000002`! That means this TMD belongs to the Wii Menu. But what version? Well, we can use the TMD's `title_version` property to check, like so:
```pycon
>>> print(tmd.title_version)
513
>>>
```
513! So now we know that this TMD is from the Wii Menu, and is version 513, which is the version number used for v4.3U.
So now you know how to identify what title and version a TMD file is from! But, realistically, trying to identify a lone unlabeled TMD file is not something you'll ever really need to do, either in your day-to-day life or in whatever program you're developing. In the next chapter, we'll dive in to working with more components of a title, which is a lot more useful for programs that need to manipulate them.
The full documentation on the TMD class can be found here: <project:#libWiiPy.title.tmd>

View File

@@ -0,0 +1,20 @@
# Installation
The first thing you'll want to do to get set up is to install the `libWiiPy` package. This can be done one of two ways.
**For a more stable experience,** you can install the latest release from PyPI just like any other Python package:
```shell
pip install libWiiPy
```
**If you prefer to live on the edge** (or just want to use features currently in development), you can also build the latest version from git:
```shell
pip install git+https://github.com/NinjaCheetah/libWiiPy
```
If you'd like to check the latest release, our PyPI page can be found [here](https://pypi.org/project/libWiiPy/). Release notes and build files for each release can be found over on our [GitHub releases page](https://github.com/NinjaCheetah/libWiiPy/releases/latest).
:::{caution}
libWiiPy is under heavy active development! While we try our hardest to not make breaking changes, things move quickly and that sometimes can cause problems.
:::
For those who are truly brave and want to experiment with the latest features, you can try building from an alternative branch. However, if you're going to do this, please be aware that features on branches other than `main` are likely very incomplete, and potentially completely broken. New features are only merged into `main` once they've been proven to at least work for their intended purpose. This does not guarantee a bug-free experience, but you are significantly less likely to run into show-stopping bugs.

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "libWiiPy" name = "libWiiPy"
version = "0.2.2" version = "0.4.1"
authors = [ authors = [
{ name="NinjaCheetah", email="ninjacheetah@ncxprogramming.com" }, { name="NinjaCheetah", email="ninjacheetah@ncxprogramming.com" },
{ name="Lillian Skinner", email="lillian@randommeaninglesscharacters.com" } { name="Lillian Skinner", email="lillian@randommeaninglesscharacters.com" }
@@ -9,18 +9,29 @@ description = "A modern Python library for handling files used by the Wii"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"
classifiers = [ 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", "License :: OSI Approved :: MIT License",
"Operating System :: OS Independent", "Operating System :: OS Independent",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
] ]
dependencies = [ dependencies = [
"pycryptodome", "pycryptodome",
"requests" "requests"
] ]
keywords = ["Wii", "wii"]
[project.urls] [project.urls]
Homepage = "https://github.com/NinjaCheetah/libWiiPy" Homepage = "https://github.com/NinjaCheetah/libWiiPy"
Issues = "https://github.com/NinjaCheetah/libWiipy/issues" Documentation = "https://ninjacheetah.github.io/libWiiPy/"
Repository = "https://github.com/NinjaCheetah/libWiiPy.git"
Issues = "https://github.com/NinjaCheetah/libWiiPy/issues"
[build-system] [build-system]
requires = ["setuptools>=61.0"] requires = ["setuptools>=61.0"]

View File

@@ -1,3 +1,9 @@
build build
pycryptodome pycryptodome
requests requests
sphinx
sphinx-book-theme
myst-parser
sphinx-copybutton
sphinx-tippy
sphinx-design

0
src/__init__.py Normal file
View File

View File

@@ -1,12 +1,9 @@
# "__init__.py" from libWiiPy by NinjaCheetah & Contributors # "__init__.py" from libWiiPy by NinjaCheetah & Contributors
# https://github.com/NinjaCheetah/libWiiPy # https://github.com/NinjaCheetah/libWiiPy
# #
# These are the essential modules from libWiiPy that you'd probably want imported by default. # These are the essential submodules from libWiiPy that you'd probably want imported by default.
from .commonkeys import * __all__ = ["archive", "title"]
from .content import *
from .ticket import * from . import archive
from .title import * from . import title
from .tmd import *
from .wad import *
from .nus import *

View File

@@ -0,0 +1,5 @@
# "archive/__init__.py" from libWiiPy by NinjaCheetah & Contributors
# https://github.com/NinjaCheetah/libWiiPy
from .ash import *
from .u8 import *

233
src/libWiiPy/archive/ash.py Normal file
View File

@@ -0,0 +1,233 @@
# "archive/ash.py" from libWiiPy by NinjaCheetah & Contributors
# https://github.com/NinjaCheetah/libWiiPy
#
# This code in particular is a direct translation of "ash-dec" from ASH0-tools. ASH0-tools is written by Garhoogin and
# co-authored by NinjaCheetah.
# https://github.com/NinjaCheetah/ASH0-tools
#
# See <link pending> for details about the ASH archive format.
import io
from dataclasses import dataclass as _dataclass
@_dataclass
class _ASHBitReader:
"""
An _ASHBitReader class used to parse individual words in an ASH file. Private class used by the ASH module.
Attributes
----------
src_data : list[int]
The entire data of the ASH file being parsed, as a list of integers for each byte.
size : int
The size of the ASH file.
src_pos : int
The position in the src_data list currently being accessed.
word : int
The word currently being decompressed.
bit_capacity : int
tree_type : str
What tree this bit reader is being used with. Used exclusively for debugging, as this value is only used in
error messages.
"""
src_data: list[int]
size: int
src_pos: int
word: int
bit_capacity: int
tree_type: str
def _ash_bit_reader_feed_word(bit_reader: _ASHBitReader):
# Ensure that there's enough data to read en entire word, then if there is, read one.
if not bit_reader.src_pos + 4 <= bit_reader.size:
print(bit_reader.src_pos)
raise ValueError("Invalid ASH data! Cannot decompress.")
bit_reader.word = int.from_bytes(bit_reader.src_data[bit_reader.src_pos:bit_reader.src_pos + 4], 'big')
bit_reader.bit_capacity = 0
bit_reader.src_pos += 4
def _ash_bit_reader_init(bit_reader: _ASHBitReader, src: list[int], size: int, start_pos: int):
# Load data into a bit reader, then have it read its first word.
bit_reader.src_data = src
bit_reader.size = size
bit_reader.src_pos = start_pos
_ash_bit_reader_feed_word(bit_reader)
def _ash_bit_reader_read_bit(bit_reader: _ASHBitReader):
# Reads the starting bit of the current word in the provided bit reader. If the capacity is at 31, then we've
# shifted through the entire word, so a new one should be fed. If not, increase the capacity by one and shift the
# current word left.
bit = bit_reader.word >> 31
if bit_reader.bit_capacity == 31:
_ash_bit_reader_feed_word(bit_reader)
else:
bit_reader.bit_capacity += 1
bit_reader.word = (bit_reader.word << 1) & 0xFFFFFFFF # This simulates a 32-bit integer.
return bit
def _ash_bit_reader_read_bits(bit_reader: _ASHBitReader, num_bits: int):
# Reads a series of bytes from the current word in the supplied bit reader.
bits: int
next_bit = bit_reader.bit_capacity + num_bits
if next_bit <= 32:
bits = bit_reader.word >> (32 - num_bits)
if next_bit != 32:
bit_reader.word = (bit_reader.word << num_bits) & 0xFFFFFFFF # This simulates a 32-bit integer (again).
bit_reader.bit_capacity += num_bits
else:
_ash_bit_reader_feed_word(bit_reader)
else:
bits = bit_reader.word >> (32 - num_bits)
_ash_bit_reader_feed_word(bit_reader)
bits |= (bit_reader.word >> (64 - next_bit))
bit_reader.word = (bit_reader.word << (next_bit - 32)) & 0xFFFFFFFF # Simulate 32-bit int.
bit_reader.bit_capacity = next_bit - 32
return bits
def _ash_read_tree(bit_reader: _ASHBitReader, width: int, left_tree: [int], right_tree: [int]):
# Read either the symbol or distance tree from the ASH file, and return the root of that tree.
work = [0] * (2 * (1 << width))
work_pos = 0
r23 = 1 << width
tree_root = 0
num_nodes = 0
while True:
if _ash_bit_reader_read_bit(bit_reader) != 0:
work[work_pos] = (r23 | 0x80000000)
work_pos += 1
work[work_pos] = (r23 | 0x40000000)
work_pos += 1
num_nodes += 2
r23 += 1
else:
tree_root = _ash_bit_reader_read_bits(bit_reader, width)
while True:
work_pos -= 1
node_value = work[work_pos]
idx = node_value & 0x3FFFFFFF
num_nodes -= 1
try:
if node_value & 0x80000000:
right_tree[idx] = tree_root
tree_root = idx
else:
left_tree[idx] = tree_root
break
except IndexError:
raise ValueError("Decompression failed while reading " + bit_reader.tree_type + " tree! Incorrect "
"leaf width may have been used. Try using a different number of bits for the " +
bit_reader.tree_type + " tree leaves.")
# Simulate a do-while loop.
if num_nodes == 0:
break
# Also a do-while.
if num_nodes == 0:
break
return tree_root
def _decompress_ash(input_data: list[int], size: int, sym_bits: int, dist_bits: int):
# Get the size of the decompressed data by reading the second 4 bytes of the file and masking the first one out.
decompressed_size = int.from_bytes(input_data[0x4:0x8]) & 0x00FFFFFF
# Array of decompressed data and the position in that array that we're at. Mimics the memory pointer from the
# original C source.
out_buffer = [0] * decompressed_size
out_buffer_pos = 0
# Create two empty bit readers, and then initialize them at two different positions for the two trees.
bit_reader1 = _ASHBitReader([0], 0, 0, 0, 0, "distance")
_ash_bit_reader_init(bit_reader1, input_data, size, int.from_bytes(input_data[0x8:0xC], byteorder='big'))
bit_reader2 = _ASHBitReader([0], 0, 0, 0, 0, "symbol")
_ash_bit_reader_init(bit_reader2, input_data, size, 0xC)
# Calculate the max for the symbol and distance trees based on the bit lengths that were passed. Then, allocate the
# arrays for all the trees based on that maximum.
sym_max = 1 << sym_bits
dist_max = 1 << dist_bits
sym_left_tree = [0] * (2 * sym_max - 1)
sym_right_tree = [0] * (2 * sym_max - 1)
dist_left_tree = [0] * (2 * dist_max - 1)
dist_right_tree = [0] * (2 * dist_max - 1)
# Read the trees to find the symbol and distance tree roots.
sym_root = _ash_read_tree(bit_reader2, sym_bits, sym_left_tree, sym_right_tree)
dist_root = _ash_read_tree(bit_reader1, dist_bits, dist_left_tree, dist_right_tree)
# Main decompression loop.
while True:
sym = sym_root
while sym >= sym_max:
if _ash_bit_reader_read_bit(bit_reader2) != 0:
sym = sym_right_tree[sym]
else:
sym = sym_left_tree[sym]
if sym < 0x100:
out_buffer[out_buffer_pos] = sym
out_buffer_pos += 1
decompressed_size -= 1
else:
dist_sym = dist_root
while dist_sym >= dist_max:
if _ash_bit_reader_read_bit(bit_reader1) != 0:
dist_sym = dist_right_tree[dist_sym]
else:
dist_sym = dist_left_tree[dist_sym]
copy_len = (sym - 0x100) + 3
srcp_pos = out_buffer_pos - dist_sym - 1
# Check to make sure we aren't going to exceed the specified decompressed size.
if not copy_len <= decompressed_size:
raise ValueError("Invalid ASH data! Cannot decompress.")
decompressed_size -= copy_len
while copy_len > 0:
out_buffer[out_buffer_pos] = out_buffer[srcp_pos]
out_buffer_pos += 1
srcp_pos += 1
copy_len -= 1
# Simulate a do-while loop.
if decompressed_size == 0:
break
return out_buffer
def decompress_ash(ash_data: bytes, sym_tree_bits: int = 9, dist_tree_bits: int = 11) -> bytes:
"""
Decompresses the data of an ASH file and returns the decompressed data.
With the default parameters, this function can decompress ASH files found in the files of the Wii Menu and Animal
Crossing: City Folk. Some ASH files, notably the ones found in the WiiWare title My Pokémon Ranch, require setting
dist_tree_bits to 15 instead for a successful decompression. If an ASH file is failing to decompress with the
default options, trying a dist_tree_bits value of 15 will likely fix it. No other leaf sizes are known to exist,
however they might be out there.
Parameters
----------
ash_data : bytes
The data for the ASH file to decompress.
sym_tree_bits : int, option
Number of bits for each leaf in the symbol tree. Defaults to 9.
dist_tree_bits : int, option
Number of bits for each leaf in the distance tree. Defaults to 11.
"""
# Check the magic number to make sure this is an ASH file.
with io.BytesIO(ash_data) as ash_data2:
ash_magic = ash_data2.read(4)
if ash_magic != b'\x41\x53\x48\x30':
raise TypeError("This is not a valid ASH file!")
# Begin decompression. Convert the compressed data to an array of ints for processing, then convert the returned
# decompressed data back into bytes to return it.
ash_size = len(ash_data)
ash_data_int = [byte for byte in ash_data]
decompressed_data = _decompress_ash(ash_data_int, ash_size, sym_tree_bits, dist_tree_bits)
decompressed_data_bin = bytes(decompressed_data)
return decompressed_data_bin

312
src/libWiiPy/archive/u8.py Normal file
View File

@@ -0,0 +1,312 @@
# "archive/u8.py" from libWiiPy by NinjaCheetah & Contributors
# https://github.com/NinjaCheetah/libWiiPy
#
# See https://wiibrew.org/wiki/U8_archive for details about the U8 archive format.
import io
import os
import pathlib
from dataclasses import dataclass as _dataclass
from typing import List
from ..shared import _align_value
@_dataclass
class _U8Node:
"""
A U8Node object that contains the data of a single node in a U8 file header. Each node keeps track of whether this
node is for a file or directory, the offset of the name of the file/directory, the offset of the data for the file/
directory, and the size of the data. Private class used by functions and methods in the U8 module.
Attributes
----------
type : int
Whether this node refers to a file or a directory. Either 0x0000 for files, or 0x0100 for directories.
name_offset : int
The offset of the name of the file/directory this node refers to.
data_offset : int
The offset of the data for the file/directory this node refers to.
size : int
The size of the data for this node.
"""
type: int
name_offset: int
data_offset: int
size: int
class U8Archive:
def __init__(self):
"""
A U8 object that allows for managing the contents of a U8 archive.
Attributes
----------
"""
self.u8_magic = b''
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
def load(self, u8_data: bytes) -> None:
"""
Loads raw U8 data into a new U8 object. This allows for extracting the file and updating its contents.
Parameters
----------
u8_data : bytes
The data for the U8 file to load.
"""
with io.BytesIO(u8_data) as u8_data:
# Read the first 4 bytes of the file to ensure that it's a U8 archive.
u8_data.seek(0x0)
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)
# The size of the U8 header.
# header_size = int(binascii.hexlify(u8_data.read(4)), 16)
# 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)
root_node_size = int.from_bytes(u8_data.read(4))
# 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_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))
# Iterate over all loaded nodes and create a list of file names and a list of file data.
name_base_offset = u8_data.tell()
for node in self.u8_node_list:
u8_data.seek(name_base_offset + node.name_offset)
name_bin = b''
while name_bin[-1:] != b'\x00':
name_bin += u8_data.read(1)
name_bin = name_bin[:-1]
name = str(name_bin.decode())
self.file_name_list.append(name)
if node.type == 0:
u8_data.seek(node.data_offset)
self.file_data_list.append(u8_data.read(node.size))
else:
self.file_data_list.append(b'')
def dump(self) -> bytes:
"""
Dumps the U8Archive object into the raw data of a U8 archive.
Returns
-------
bytes
The full U8 archive as bytes.
"""
# This is 0 because the header size DOES NOT include the initial 32 bytes describing the file.
header_size = 0
# Add 12 bytes for each node, since that's how many bytes each one is made up of.
for node in range(len(self.u8_node_list)):
header_size += 12
# 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)
# 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
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.
u8_data = b''
# Magic number.
u8_data += b'\x55\xAA\x38\x2D'
# Root node offset (this is always 0x20).
u8_data += int.to_bytes(0x20, 4)
# Size of the file header (excluding the first 32 bytes).
u8_data += int.to_bytes(header_size, 4)
# Offset of the beginning of the data region of the U8 archive.
u8_data += int.to_bytes(data_offset, 4)
# 16 bytes of zeroes.
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.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'
# Iterate all file data and dump it.
for file in self.file_data_list:
u8_data += file
# Return the U8 archive.
return u8_data
def extract_u8(u8_data, output_folder) -> None:
"""
Extracts the provided U8 archive file data into the provided output folder path. Note that the folder must not
already exist to ensure that the output can correctly represent the file structure of the original U8 archive.
Parameters
----------
u8_data : bytes
The data for the U8 file to extract.
output_folder : str
The path to a new folder to extract the archive to.
"""
output_folder = pathlib.Path(output_folder)
# Check if the path already exists, and if it does, ensure that it is both a directory and empty.
if output_folder.exists():
if output_folder.is_dir() and next(os.scandir(output_folder), None):
raise ValueError("Output folder is not empty!")
elif output_folder.is_file():
raise ValueError("A file already exists with the provided name!")
else:
os.mkdir(output_folder)
# Create a new U8Archive object and load the provided U8 file data into it.
u8_archive = U8Archive()
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.
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]:
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)
# 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!")
def _pack_u8_dir(u8_archive: U8Archive, current_path, node_count, name_offset):
# 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.
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)
# 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.
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.
# 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.
for directory in dir_list:
node_count += 1
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)
# Return the U8Archive object, the current node we're on, and the current name offset.
return u8_archive, node_count, name_offset
def pack_u8(input_path) -> bytes:
"""
Packs the provided file or folder into a new U8 archive, and returns the raw file data for it.
Parameters
----------
input_path
The path to the input file or folder.
Returns
-------
u8_archive : bytes
The data for the packed U8 archive.
"""
input_path = pathlib.Path(input_path)
if os.path.isdir(input_path):
# 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))
# Call the private function _pack_u8_dir() on the root note, which will recursively call itself to pack every
# subdirectory and file. Discard node_count and name_offset since we don't care about them here, as they're
# really only necessary for the directory recursion.
u8_archive, _, _ = _pack_u8_dir(u8_archive, input_path, node_count=1, name_offset=1)
return u8_archive.dump()
elif os.path.isfile(input_path):
# Simple code to handle if a single file is provided as input. Not really sure *why* you'd do this, since the
# whole point of a U8 archive is to stitch files together, but it's here nonetheless.
with open(input_path, "rb") as f:
u8_archive = U8Archive()
file_name = input_path.name
file_data = f.read()
# Append blank file name for the root node.
u8_archive.file_name_list.append("")
u8_archive.file_name_list.append(file_name)
# Append blank data for the root node.
u8_archive.file_data_list.append(b'')
u8_archive.file_data_list.append(file_data)
# Append generic U8Node for the root, followed by the actual file's node.
u8_archive.u8_node_list.append(_U8Node(256, 0, 0, 2))
u8_archive.u8_node_list.append(_U8Node(0, 1, 0, len(file_data)))
return u8_archive.dump()
else:
raise FileNotFoundError("Input file/directory: \"" + str(input_path) + "\" does not exist!")

View File

@@ -4,9 +4,10 @@
# This file defines general functions that may be useful in other modules of libWiiPy. Putting them here cuts down on # This file defines general functions that may be useful in other modules of libWiiPy. Putting them here cuts down on
# clutter in other files. # clutter in other files.
def align_value(value, alignment=64) -> int:
def _align_value(value, alignment=64) -> int:
""" """
Aligns the provided value to the set alignment (defaults to 64). Aligns the provided value to the set alignment (defaults to 64). Private function used by other libWiiPy modules.
Parameters Parameters
---------- ----------
@@ -26,22 +27,23 @@ def align_value(value, alignment=64) -> int:
return value return value
def pad_bytes_stream(data, alignment=64) -> bytes: def _pad_bytes(data, alignment=64) -> bytes:
""" """
Pads the provided bytes stream to the provided alignment (defaults to 64). Pads the provided bytes object to the provided alignment (defaults to 64). Private function used by other libWiiPy
modules.
Parameters Parameters
---------- ----------
data : BytesIO data : bytes
The data to align. The data to align.
alignment : int alignment : int
The number to align to. Defaults to 64. The number to align to. Defaults to 64.
Returns Returns
------- -------
BytesIO bytes
The aligned data. The aligned data.
""" """
while (data.getbuffer().nbytes % alignment) != 0: while (len(data) % alignment) != 0:
data.write(b'\x00') data += b'\x00'
return data return data

View File

@@ -0,0 +1,10 @@
# "title/__init__.py" from libWiiPy by NinjaCheetah & Contributors
# https://github.com/NinjaCheetah/libWiiPy
from .content import *
from .crypto import *
from .nus import *
from .ticket import *
from .title import *
from .tmd import *
from .wad import *

View File

@@ -1,4 +1,4 @@
# "commonkeys.py" from libWiiPy by NinjaCheetah & Contributors # "title/commonkeys.py" from libWiiPy by NinjaCheetah & Contributors
# https://github.com/NinjaCheetah/libWiiPy # https://github.com/NinjaCheetah/libWiiPy
import binascii import binascii

View File

@@ -1,13 +1,13 @@
# "content.py" from libWiiPy by NinjaCheetah & Contributors # "title/content.py" from libWiiPy by NinjaCheetah & Contributors
# https://github.com/NinjaCheetah/libWiiPy # https://github.com/NinjaCheetah/libWiiPy
# #
# See https://wiibrew.org/wiki/Title for details about how titles are formatted # See https://wiibrew.org/wiki/Title for details about how titles are formatted
import io import io
import sys
import hashlib import hashlib
from typing import List from typing import List
from .types import ContentRecord from ..types import _ContentRecord
from ..shared import _pad_bytes, _align_value
from .crypto import decrypt_content, encrypt_content from .crypto import decrypt_content, encrypt_content
@@ -18,20 +18,20 @@ class ContentRegion:
Attributes Attributes
---------- ----------
content_records : List[ContentRecord] content_records : List[_ContentRecord]
The content records for the content stored in the region. The content records for the content stored in the region.
num_contents : int num_contents : int
The total number of contents stored in the region. The total number of contents stored in the region.
""" """
def __init__(self): def __init__(self):
self.content_records: List[ContentRecord] = [] self.content_records: List[_ContentRecord] = []
self.content_region_size: int = 0 # Size of the content region. self.content_region_size: int = 0 # Size of the content region.
self.num_contents: int = 0 # Number of contents in the content region. self.num_contents: int = 0 # Number of contents in the content region.
self.content_start_offsets: List[int] = [0] # The start offsets of each content in the content region. self.content_start_offsets: List[int] = [0] # The start offsets of each content in the content region.
self.content_list: List[bytes] = [] self.content_list: List[bytes] = []
def load(self, content_region: bytes, content_records: List[ContentRecord]) -> None: def load(self, content_region: bytes, content_records: List[_ContentRecord]) -> None:
""" """
Loads the raw content region and builds a list of all the contents. Loads the raw content region and builds a list of all the contents.
@@ -39,13 +39,13 @@ class ContentRegion:
---------- ----------
content_region : bytes content_region : bytes
The raw data for the content region being loaded. The raw data for the content region being loaded.
content_records : list[ContentRecord] content_records : list[_ContentRecord]
A list of ContentRecord objects detailing all contents contained in the region. A list of ContentRecord objects detailing all contents contained in the region.
""" """
self.content_records = content_records self.content_records = content_records
# Get the total size of the content region.
self.content_region_size = len(content_region)
with io.BytesIO(content_region) as content_region_data: with io.BytesIO(content_region) as content_region_data:
# Get the total size of the content region.
self.content_region_size = sys.getsizeof(content_region_data)
self.num_contents = len(self.content_records) self.num_contents = len(self.content_records)
# Calculate the offsets of each content in the content region. # Calculate the offsets of each content in the content region.
# Content is aligned to 16 bytes, however a new content won't start until the next multiple of 64 bytes. # Content is aligned to 16 bytes, however a new content won't start until the next multiple of 64 bytes.
@@ -56,7 +56,7 @@ class ContentRegion:
start_offset += 64 - (content.content_size % 64) start_offset += 64 - (content.content_size % 64)
self.content_start_offsets.append(start_offset) self.content_start_offsets.append(start_offset)
# Build a list of all the encrypted content data. # Build a list of all the encrypted content data.
for content in range(len(self.content_start_offsets)): for content in range(self.num_contents):
# Seek to the start of the content based on the list of offsets. # Seek to the start of the content based on the list of offsets.
content_region_data.seek(self.content_start_offsets[content]) content_region_data.seek(self.content_start_offsets[content])
# Calculate the number of bytes we need to read by adding bytes up the nearest multiple of 16 if needed. # Calculate the number of bytes we need to read by adding bytes up the nearest multiple of 16 if needed.
@@ -68,7 +68,7 @@ class ContentRegion:
content_enc = content_region_data.read(bytes_to_read) content_enc = content_region_data.read(bytes_to_read)
self.content_list.append(content_enc) self.content_list.append(content_enc)
def dump(self) -> bytes: def dump(self) -> tuple[bytes, int]:
""" """
Takes the list of contents and assembles them back into one content region. Returns this content region as a Takes the list of contents and assembles them back into one content region. Returns this content region as a
bytes object and sets the raw content region variable to this result, then calls load() again to make sure the bytes object and sets the raw content region variable to this result, then calls load() again to make sure the
@@ -77,23 +77,31 @@ class ContentRegion:
Returns Returns
------- -------
bytes bytes
The full WAD file as bytes. The full ContentRegion as bytes, including padding between content.
int
The size of the ContentRegion, including padding.
""" """
# Open the stream and begin writing data to it. content_region_data = b''
with io.BytesIO() as content_region_data: for content in self.content_list:
for content in self.content_list: # If this isn't the first content, pad the whole region to 64 bytes before the next one.
# Calculate padding after this content before the next one. if content_region_data is not b'':
padding_bytes = 0 content_region_data = _pad_bytes(content_region_data, 64)
if (len(content) % 64) != 0: # Calculate padding after this content before the next one.
padding_bytes = 64 - (len(content) % 64) padding_bytes = 0
# Write content data, then the padding afterward if necessary. if (len(content) % 16) != 0:
content_region_data.write(content) padding_bytes = 16 - (len(content) % 16)
if padding_bytes > 0: # Write content data, then the padding afterward if necessary.
content_region_data.write(b'\x00' * padding_bytes) content_region_data += content
content_region_data.seek(0x0) if padding_bytes > 0:
content_region_raw = content_region_data.read() content_region_data += b'\x00' * padding_bytes
# Return the raw ContentRegion for the data contained in the object. # Calculate the size of the whole content region.
return content_region_raw content_region_size = 0
for record in range(len(self.content_records)):
if record is len(self.content_records) - 1:
content_region_size += self.content_records[record].content_size
else:
content_region_size += _align_value(self.content_records[record].content_size, 64)
return content_region_data, content_region_size
def get_enc_content_by_index(self, index: int) -> bytes: def get_enc_content_by_index(self, index: int) -> bytes:
""" """
@@ -177,7 +185,7 @@ class ContentRegion:
# Compare the hash and throw a ValueError if the hash doesn't match. # Compare the hash and throw a ValueError if the hash doesn't match.
if content_dec_hash != content_record_hash: if content_dec_hash != content_record_hash:
raise ValueError("Content hash did not match the expected hash in its record! The incorrect Title Key may " raise ValueError("Content hash did not match the expected hash in its record! The incorrect Title Key may "
"have been used!.\n" "have been used!\n"
"Expected hash is: {}\n".format(content_record_hash) + "Expected hash is: {}\n".format(content_record_hash) +
"Actual hash is: {}".format(content_dec_hash)) "Actual hash is: {}".format(content_dec_hash))
return content_dec return content_dec
@@ -260,7 +268,7 @@ class ContentRegion:
if (index + 1) > num_contents + 1: if (index + 1) > num_contents + 1:
raise ValueError("You are trying to set the content at position " + str(index) + ", but no content " raise ValueError("You are trying to set the content at position " + str(index) + ", but no content "
"exists at position " + str(index - 1) + "!") "exists at position " + str(index - 1) + "!")
self.content_records.append(ContentRecord(cid, index, content_type, content_size, content_hash)) self.content_records.append(_ContentRecord(cid, index, content_type, content_size, content_hash))
# If it does, reassign the values in it. # If it does, reassign the values in it.
else: else:
self.content_records[index].content_id = cid self.content_records[index].content_id = cid

View File

@@ -1,12 +1,36 @@
# "crypto.py" from libWiiPy by NinjaCheetah & Contributors # "title/crypto.py" from libWiiPy by NinjaCheetah & Contributors
# https://github.com/NinjaCheetah/libWiiPy # https://github.com/NinjaCheetah/libWiiPy
import struct import struct
import binascii
from .commonkeys import get_common_key from .commonkeys import get_common_key
from Crypto.Cipher import AES from Crypto.Cipher import AES as _AES
def decrypt_title_key(title_key_enc, common_key_index, title_id) -> bytes: def _convert_tid_to_iv(title_id: str) -> 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''
if type(title_id) is bytes:
# This catches the format b'0000000100000002'
if len(title_id) == 16:
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
# 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:
title_key_iv = binascii.unhexlify(title_id)
# If the Title ID isn't bytes or a string, it isn't valid and is rejected.
else:
raise TypeError("Title ID type is not valid! It must be either type str or bytes.")
return title_key_iv
def decrypt_title_key(title_key_enc: bytes, common_key_index: int, title_id: bytes | str) -> bytes:
""" """
Gets the decrypted version of the encrypted Title Key provided. Gets the decrypted version of the encrypted Title Key provided.
@@ -17,9 +41,9 @@ def decrypt_title_key(title_key_enc, common_key_index, title_id) -> bytes:
title_key_enc : bytes title_key_enc : bytes
The encrypted Title Key. The encrypted Title Key.
common_key_index : int common_key_index : int
The index of the common key to be returned. The index of the common key used to encrypt the Title Key.
title_id : bytes title_id : bytes, str
The title ID of the title that the key is for. The Title ID of the title that the key is for.
Returns Returns
------- -------
@@ -28,15 +52,50 @@ def decrypt_title_key(title_key_enc, common_key_index, title_id) -> bytes:
""" """
# Load the correct common key for the title. # Load the correct common key for the title.
common_key = get_common_key(common_key_index) common_key = get_common_key(common_key_index)
# Calculate the IV by adding 8 bytes to the end of the Title ID. # Convert the IV into the correct format based on the type provided.
title_key_iv = title_id + (b'\x00' * 8) title_key_iv = _convert_tid_to_iv(title_id)
# The IV will always be in the same format by this point, so add the last 8 bytes.
title_key_iv = title_key_iv + (b'\x00' * 8)
# Create a new AES object with the values provided. # Create a new AES object with the values provided.
aes = AES.new(common_key, AES.MODE_CBC, title_key_iv) aes = _AES.new(common_key, _AES.MODE_CBC, title_key_iv)
# Decrypt the Title Key using the AES object. # Decrypt the Title Key using the AES object.
title_key = aes.decrypt(title_key_enc) title_key = aes.decrypt(title_key_enc)
return title_key return title_key
def encrypt_title_key(title_key_dec: bytes, common_key_index: int, title_id: bytes | str) -> bytes:
"""
Encrypts the provided Title Key with the selected common key.
Requires the index of the common key to use, and the Title ID of the title that the Title Key is for.
Parameters
----------
title_key_dec : bytes
The decrypted Title Key.
common_key_index : int
The index of the common key used to encrypt the Title Key.
title_id : bytes, str
The Title ID of the title that the key is for.
Returns
-------
bytes
An encrypted Title Key.
"""
# Load the correct common key for the title.
common_key = get_common_key(common_key_index)
# Convert the IV into the correct format based on the type provided.
title_key_iv = _convert_tid_to_iv(title_id)
# The IV will always be in the same format by this point, so add the last 8 bytes.
title_key_iv = title_key_iv + (b'\x00' * 8)
# Create a new AES object with the values provided.
aes = _AES.new(common_key, _AES.MODE_CBC, title_key_iv)
# Encrypt Title Key using the AES object.
title_key = aes.encrypt(title_key_dec)
return title_key
def decrypt_content(content_enc, title_key, content_index, content_length) -> bytes: def decrypt_content(content_enc, title_key, content_index, content_length) -> bytes:
""" """
Gets the decrypted version of the encrypted content. Gets the decrypted version of the encrypted content.
@@ -68,7 +127,7 @@ def decrypt_content(content_enc, title_key, content_index, content_length) -> by
if (len(content_enc) % 16) != 0: if (len(content_enc) % 16) != 0:
content_enc = content_enc + (b'\x00' * (16 - (len(content_enc) % 16))) content_enc = content_enc + (b'\x00' * (16 - (len(content_enc) % 16)))
# Create a new AES object with the values provided, with the content's unique ID as the IV. # Create a new AES object with the values provided, with the content's unique ID as the IV.
aes = AES.new(title_key, AES.MODE_CBC, content_index_bin) aes = _AES.new(title_key, _AES.MODE_CBC, content_index_bin)
# Decrypt the content using the AES object. # Decrypt the content using the AES object.
content_dec = aes.decrypt(content_enc) content_dec = aes.decrypt(content_enc)
# Trim additional bytes that may have been added so the content is the correct size. # Trim additional bytes that may have been added so the content is the correct size.
@@ -107,7 +166,7 @@ def encrypt_content(content_dec, title_key, content_index) -> bytes:
if (len(content_dec) % 16) != 0: if (len(content_dec) % 16) != 0:
content_dec = content_dec + (b'\x00' * (16 - (len(content_dec) % 16))) content_dec = content_dec + (b'\x00' * (16 - (len(content_dec) % 16)))
# Create a new AES object with the values provided, with the content's unique ID as the IV. # Create a new AES object with the values provided, with the content's unique ID as the IV.
aes = AES.new(title_key, AES.MODE_CBC, content_index_bin) aes = _AES.new(title_key, _AES.MODE_CBC, content_index_bin)
# Encrypt the content using the AES object. # Encrypt the content using the AES object.
content_enc = aes.encrypt(content_dec) content_enc = aes.encrypt(content_dec)
# Trim down the encrypted content. # Trim down the encrypted content.

View File

@@ -1,9 +1,8 @@
# "nus.py" from libWiiPy by NinjaCheetah & Contributors # "title/nus.py" from libWiiPy by NinjaCheetah & Contributors
# https://github.com/NinjaCheetah/libWiiPy # https://github.com/NinjaCheetah/libWiiPy
# #
# See https://wiibrew.org/wiki/NUS for details about the NUS # See https://wiibrew.org/wiki/NUS for details about the NUS
import io
import requests import requests
import hashlib import hashlib
from typing import List from typing import List
@@ -11,8 +10,10 @@ from .title import Title
from .tmd import TMD from .tmd import TMD
from .ticket import Ticket from .ticket import Ticket
_nus_endpoint = ["http://nus.cdn.shop.wii.com/ccs/download/", "http://ccs.cdn.wup.shop.nintendo.net/ccs/download/"]
def download_title(title_id: str, title_version: int = None) -> Title:
def download_title(title_id: str, title_version: int = None, wiiu_endpoint: bool = False) -> Title:
""" """
Download an entire title and all of its contents, then load the downloaded components into a Title object for Download an entire title and all of its contents, then load the downloaded components into a Title object for
further use. This method is NOT recommended for general use, as it has absolutely no verbosity. It is instead further use. This method is NOT recommended for general use, as it has absolutely no verbosity. It is instead
@@ -24,6 +25,8 @@ def download_title(title_id: str, title_version: int = None) -> Title:
The Title ID of the title to download. The Title ID of the title to download.
title_version : int, option title_version : int, option
The version of the title to download. Defaults to latest if not set. The version of the title to download. Defaults to latest if not set.
wiiu_endpoint : bool, option
Whether the Wii U endpoint for the NUS should be used or not. This increases download speeds. Defaults to False.
Returns Returns
------- -------
@@ -33,17 +36,17 @@ def download_title(title_id: str, title_version: int = None) -> Title:
# First, create the new title. # First, create the new title.
title = Title() title = Title()
# Download and load the TMD, Ticket, and certs. # Download and load the TMD, Ticket, and certs.
title.load_tmd(download_tmd(title_id, title_version)) title.load_tmd(download_tmd(title_id, title_version, wiiu_endpoint))
title.load_ticket(download_ticket(title_id)) title.load_ticket(download_ticket(title_id, wiiu_endpoint))
title.wad.set_cert_data(download_cert()) title.wad.set_cert_data(download_cert(wiiu_endpoint))
# Download all contents # Download all contents
title.load_content_records() title.load_content_records()
title.content.content_list = download_contents(title_id, title.tmd) title.content.content_list = download_contents(title_id, title.tmd, wiiu_endpoint)
# Return the completed title. # Return the completed title.
return title return title
def download_tmd(title_id: str, title_version: int = None) -> bytes: def download_tmd(title_id: str, title_version: int = None, wiiu_endpoint: bool = False) -> bytes:
""" """
Downloads the TMD of the Title specified in the object. Will download the latest version by default, or another Downloads the TMD of the Title specified in the object. Will download the latest version by default, or another
version if it was manually specified in the object. version if it was manually specified in the object.
@@ -54,6 +57,8 @@ def download_tmd(title_id: str, title_version: int = None) -> bytes:
The Title ID of the title to download the TMD for. The Title ID of the title to download the TMD for.
title_version : int, option title_version : int, option
The version of the TMD to download. Defaults to latest if not set. The version of the TMD to download. Defaults to latest if not set.
wiiu_endpoint : bool, option
Whether the Wii U endpoint for the NUS should be used or not. This increases download speeds. Defaults to False.
Returns Returns
------- -------
@@ -62,7 +67,10 @@ def download_tmd(title_id: str, title_version: int = None) -> bytes:
""" """
# Build the download URL. The structure is download/<TID>/tmd for latest and download/<TID>/tmd.<version> for # Build the download URL. The structure is download/<TID>/tmd for latest and download/<TID>/tmd.<version> for
# when a specific version is requested. # when a specific version is requested.
tmd_url = "http://nus.cdn.shop.wii.com/ccs/download/" + title_id + "/tmd" if wiiu_endpoint is False:
tmd_url = _nus_endpoint[0] + title_id + "/tmd"
else:
tmd_url = _nus_endpoint[1] + title_id + "/tmd"
# Add the version to the URL if one was specified. # Add the version to the URL if one was specified.
if title_version is not None: if title_version is not None:
tmd_url += "." + str(title_version) tmd_url += "." + str(title_version)
@@ -81,7 +89,7 @@ def download_tmd(title_id: str, title_version: int = None) -> bytes:
return tmd return tmd
def download_ticket(title_id: str) -> bytes: def download_ticket(title_id: str, wiiu_endpoint: bool = False) -> bytes:
""" """
Downloads the Ticket of the Title specified in the object. This will only work if the Title ID specified is for Downloads the Ticket of the Title specified in the object. This will only work if the Title ID specified is for
a free title. a free title.
@@ -90,6 +98,8 @@ def download_ticket(title_id: str) -> bytes:
---------- ----------
title_id : str title_id : str
The Title ID of the title to download the Ticket for. The Title ID of the title to download the Ticket for.
wiiu_endpoint : bool, option
Whether the Wii U endpoint for the NUS should be used or not. This increases download speeds. Defaults to False.
Returns Returns
------- -------
@@ -98,7 +108,10 @@ def download_ticket(title_id: str) -> bytes:
""" """
# Build the download URL. The structure is download/<TID>/cetk, and cetk will only exist if this is a free # Build the download URL. The structure is download/<TID>/cetk, and cetk will only exist if this is a free
# title. # title.
ticket_url = "http://nus.cdn.shop.wii.com/ccs/download/" + title_id + "/cetk" if wiiu_endpoint is False:
ticket_url = _nus_endpoint[0] + title_id + "/cetk"
else:
ticket_url = _nus_endpoint[1] + title_id + "/cetk"
# Make the request. # Make the request.
ticket_request = requests.get(url=ticket_url, headers={'User-Agent': 'wii libnup/1.0'}, stream=True) ticket_request = requests.get(url=ticket_url, headers={'User-Agent': 'wii libnup/1.0'}, stream=True)
if ticket_request.status_code != 200: if ticket_request.status_code != 200:
@@ -113,37 +126,44 @@ def download_ticket(title_id: str) -> bytes:
return ticket return ticket
def download_cert() -> bytes: def download_cert(wiiu_endpoint: bool = False) -> bytes:
""" """
Downloads the signing certificate used by all WADs. This uses System Menu 4.3U as the source. Downloads the signing certificate used by all WADs. This uses System Menu 4.3U as the source.
Parameters
----------
wiiu_endpoint : bool, option
Whether the Wii U endpoint for the NUS should be used or not. This increases download speeds. Defaults to False.
Returns Returns
------- -------
bytes bytes
The cert file. The cert file.
""" """
# Download the TMD and cetk for the System Menu 4.3U. # Download the TMD and cetk for the System Menu 4.3U.
tmd = requests.get(url='http://nus.cdn.shop.wii.com/ccs/download/0000000100000002/tmd.513', if wiiu_endpoint is False:
headers={'User-Agent': 'wii libnup/1.0'}, stream=True).content tmd_url = _nus_endpoint[0] + "0000000100000002/tmd.513"
cetk = requests.get(url='http://nus.cdn.shop.wii.com/ccs/download/0000000100000002/cetk', cetk_url = _nus_endpoint[0] + "0000000100000002/cetk"
headers={'User-Agent': 'wii libnup/1.0'}, stream=True).content else:
tmd_url = _nus_endpoint[1] + "0000000100000002/tmd.513"
cetk_url = _nus_endpoint[1] + "0000000100000002/cetk"
tmd = requests.get(url=tmd_url, headers={'User-Agent': 'wii libnup/1.0'}, stream=True).content
cetk = requests.get(url=cetk_url, headers={'User-Agent': 'wii libnup/1.0'}, stream=True).content
# Assemble the certificate. # Assemble the certificate.
with io.BytesIO() as cert_data: cert = b''
# Certificate Authority data. # Certificate Authority data.
cert_data.write(cetk[0x2A4 + 768:]) cert += cetk[0x2A4 + 768:]
# Certificate Policy data. # Certificate Policy data.
cert_data.write(tmd[0x328:0x328 + 768]) cert += tmd[0x328:0x328 + 768]
# XS data. # XS data.
cert_data.write(cetk[0x2A4:0x2A4 + 768]) cert += cetk[0x2A4:0x2A4 + 768]
cert_data.seek(0x0)
cert = cert_data.read()
# Since the cert is always the same, check the hash to make sure nothing went wildly wrong. # Since the cert is always the same, check the hash to make sure nothing went wildly wrong.
if hashlib.sha1(cert).hexdigest() != "ace0f15d2a851c383fe4657afc3840d6ffe30ad0": if hashlib.sha1(cert).hexdigest() != "ace0f15d2a851c383fe4657afc3840d6ffe30ad0":
raise Exception("An unknown error has occurred downloading and creating the certificate.") raise Exception("An unknown error has occurred downloading and creating the certificate.")
return cert return cert
def download_content(title_id: str, content_id: int) -> bytes: def download_content(title_id: str, content_id: int, wiiu_endpoint: bool = False) -> bytes:
""" """
Downloads a specified content for the title specified in the object. Downloads a specified content for the title specified in the object.
@@ -153,6 +173,8 @@ def download_content(title_id: str, content_id: int) -> bytes:
The Title ID of the title to download content from. The Title ID of the title to download content from.
content_id : int content_id : int
The Content ID of the content you wish to download. The Content ID of the content you wish to download.
wiiu_endpoint : bool, option
Whether the Wii U endpoint for the NUS should be used or not. This increases download speeds. Defaults to False.
Returns Returns
------- -------
@@ -163,7 +185,10 @@ def download_content(title_id: str, content_id: int) -> bytes:
content_id_hex = hex(content_id)[2:] content_id_hex = hex(content_id)[2:]
if len(content_id_hex) < 2: if len(content_id_hex) < 2:
content_id_hex = "0" + content_id_hex content_id_hex = "0" + content_id_hex
content_url = "http://nus.cdn.shop.wii.com/ccs/download/" + title_id + "/000000" + content_id_hex if wiiu_endpoint is False:
content_url = _nus_endpoint[0] + title_id + "/000000" + content_id_hex
else:
content_url = _nus_endpoint[1] + title_id + "/000000" + content_id_hex
# Make the request. # Make the request.
content_request = requests.get(url=content_url, headers={'User-Agent': 'wii libnup/1.0'}, stream=True) content_request = requests.get(url=content_url, headers={'User-Agent': 'wii libnup/1.0'}, stream=True)
if content_request.status_code != 200: if content_request.status_code != 200:
@@ -174,7 +199,7 @@ def download_content(title_id: str, content_id: int) -> bytes:
return content_data return content_data
def download_contents(title_id: str, tmd: TMD) -> List[bytes]: def download_contents(title_id: str, tmd: TMD, wiiu_endpoint: bool = False) -> List[bytes]:
""" """
Downloads all the contents for the title specified in the object. This requires a TMD to already be available Downloads all the contents for the title specified in the object. This requires a TMD to already be available
so that the content records can be accessed. so that the content records can be accessed.
@@ -185,6 +210,8 @@ def download_contents(title_id: str, tmd: TMD) -> List[bytes]:
The Title ID of the title to download content from. The Title ID of the title to download content from.
tmd : TMD tmd : TMD
The TMD that matches the title that the contents being downloaded are from. The TMD that matches the title that the contents being downloaded are from.
wiiu_endpoint : bool, option
Whether the Wii U endpoint for the NUS should be used or not. This increases download speeds. Defaults to False.
Returns Returns
------- -------
@@ -201,6 +228,6 @@ def download_contents(title_id: str, tmd: TMD) -> List[bytes]:
content_list = [] content_list = []
for content_id in content_ids: for content_id in content_ids:
# Call self.download_content() for each Content ID. # Call self.download_content() for each Content ID.
content = download_content(title_id, content_id) content = download_content(title_id, content_id, wiiu_endpoint)
content_list.append(content) content_list.append(content)
return content_list return content_list

View File

@@ -1,15 +1,38 @@
# "ticket.py" from libWiiPy by NinjaCheetah & Contributors # "title/ticket.py" from libWiiPy by NinjaCheetah & Contributors
# https://github.com/NinjaCheetah/libWiiPy # https://github.com/NinjaCheetah/libWiiPy
# #
# See https://wiibrew.org/wiki/Ticket for details about the ticket format # See https://wiibrew.org/wiki/Ticket for details about the ticket format
import io import io
import binascii import binascii
import hashlib
from dataclasses import dataclass as _dataclass
from .crypto import decrypt_title_key from .crypto import decrypt_title_key
from .types import TitleLimit
from typing import List from typing import List
@_dataclass
class _TitleLimit:
"""
A TitleLimit object that contains the type of restriction and the limit. The limit type can be one of the following:
0 = None, 1 = Time Limit, 3 = None, or 4 = Launch Count. The maximum usage is then either the time in minutes the
title can be played or the maximum number of launches allowed for that title, based on the type of limit applied.
Private class used only by the Ticket class.
Attributes
----------
limit_type : int
The type of play limit applied.
maximum_usage : int
The maximum value for the type of play limit applied.
"""
# The type of play limit applied.
# 0 = None, 1 = Time Limit, 3 = None, 4 = Launch Count
limit_type: int
# The maximum value of the limit applied.
maximum_usage: int
class Ticket: class Ticket:
""" """
A Ticket object that allows for either loading and editing an existing Ticket or creating one manually if desired. A Ticket object that allows for either loading and editing an existing Ticket or creating one manually if desired.
@@ -47,12 +70,14 @@ class Ticket:
self.unknown1: bytes = b'' # Some unknown data, not always the same so reading it just in case. self.unknown1: bytes = b'' # Some unknown data, not always the same so reading it just in case.
self.title_version: int = 0 # Version of the ticket's associated title. self.title_version: int = 0 # Version of the ticket's associated title.
self.permitted_titles: bytes = b'' # Permitted titles mask self.permitted_titles: bytes = b'' # Permitted titles mask
self.permit_mask: bytes = b'' # "Permit mask. The current disc title is ANDed with the inverse of this mask to see if the result matches the Permitted Titles Mask." # "Permit mask. The current disc title is ANDed with the inverse of this mask to see if the result matches the
# Permitted Titles Mask."
self.permit_mask: bytes = b''
self.title_export_allowed: int = 0 # Whether title export is allowed with a PRNG key or not. self.title_export_allowed: int = 0 # Whether title export is allowed with a PRNG key or not.
self.common_key_index: int = 0 # Which common key should be used. 0 = Common Key, 1 = Korean Key, 2 = vWii Key self.common_key_index: int = 0 # Which common key should be used. 0 = Common Key, 1 = Korean Key, 2 = vWii Key
self.unknown2: bytes = b'' # More unknown data. Varies for VC/non-VC titles so reading it to ensure it matches. self.unknown2: bytes = b'' # More unknown data. Varies for VC/non-VC titles so reading it to ensure it matches.
self.content_access_permissions: bytes = b'' # "Content access permissions (one bit for each content)" self.content_access_permissions: bytes = b'' # "Content access permissions (one bit for each content)"
self.title_limits_list: List[TitleLimit] = [] # List of play limits applied to the title. self.title_limits_list: List[_TitleLimit] = [] # List of play limits applied to the title.
# v1 ticket data # v1 ticket data
# TODO: Write in v1 ticket attributes here. This code can currently only handle v0 tickets, and will reject v1. # TODO: Write in v1 ticket attributes here. This code can currently only handle v0 tickets, and will reject v1.
@@ -99,10 +124,9 @@ class Ticket:
self.console_id = int.from_bytes(ticket_data.read(4)) self.console_id = int.from_bytes(ticket_data.read(4))
# Title ID. # Title ID.
ticket_data.seek(0x1DC) ticket_data.seek(0x1DC)
self.title_id = ticket_data.read(8) self.title_id = binascii.hexlify(ticket_data.read(8))
# Title ID (as a string). # Title ID (as a string).
title_id_hex = binascii.hexlify(self.title_id) self.title_id_str = str(self.title_id.decode())
self.title_id_str = str(title_id_hex.decode())
# Unknown data 1. # Unknown data 1.
ticket_data.seek(0x1E4) ticket_data.seek(0x1E4)
self.unknown1 = ticket_data.read(2) self.unknown1 = ticket_data.read(2)
@@ -135,80 +159,106 @@ class Ticket:
for limit in range(0, 8): for limit in range(0, 8):
limit_type = int.from_bytes(ticket_data.read(4)) limit_type = int.from_bytes(ticket_data.read(4))
limit_value = int.from_bytes(ticket_data.read(4)) limit_value = int.from_bytes(ticket_data.read(4))
self.title_limits_list.append(TitleLimit(limit_type, limit_value)) self.title_limits_list.append(_TitleLimit(limit_type, limit_value))
def dump(self) -> bytes: def dump(self) -> bytes:
""" """
Dumps the Ticket object back into bytes. This also sets the raw Ticket attribute of Ticket object to the Dumps the Ticket object back into bytes.
dumped data, and triggers load() again to ensure that the raw data and object match.
Returns Returns
------- -------
bytes bytes
The full Ticket file as bytes. The full Ticket file as bytes.
""" """
# Open the stream and begin writing to it. ticket_data = b''
with io.BytesIO() as ticket_data: # Signature type.
# Signature type. ticket_data += self.signature_type
ticket_data.write(self.signature_type) # Signature data.
# Signature data. ticket_data += self.signature
ticket_data.write(self.signature) # Padding to 64 bytes.
# Padding to 64 bytes. ticket_data += b'\x00' * 60
ticket_data.write(b'\x00' * 60) # Signature issuer.
# Signature issuer. ticket_data += str.encode(self.signature_issuer)
ticket_data.write(str.encode(self.signature_issuer)) # ECDH data.
# ECDH data. ticket_data += self.ecdh_data
ticket_data.write(self.ecdh_data) # Ticket version.
# Ticket version. ticket_data += int.to_bytes(self.ticket_version, 1)
ticket_data.write(int.to_bytes(self.ticket_version, 1)) # Reserved (all \0x00).
# Reserved (all \0x00). ticket_data += b'\x00\x00'
ticket_data.write(b'\x00\x00') # Title Key.
# Title Key. ticket_data += self.title_key_enc
ticket_data.write(self.title_key_enc) # Unknown (write \0x00).
# Unknown (write \0x00). ticket_data += b'\x00'
ticket_data.write(b'\x00') # Ticket ID.
# Ticket ID. ticket_data += self.ticket_id
ticket_data.write(self.ticket_id) # Console ID.
# Console ID. ticket_data += int.to_bytes(self.console_id, 4)
ticket_data.write(int.to_bytes(self.console_id, 4)) # Title ID.
# Title ID. ticket_data += binascii.unhexlify(self.title_id)
ticket_data.write(self.title_id) # Unknown data 1.
# Unknown data 1. ticket_data += self.unknown1
ticket_data.write(self.unknown1) # Title version.
# Title version. title_version_high = round(self.title_version / 256)
title_version_high = round(self.title_version / 256) ticket_data += int.to_bytes(title_version_high, 1)
ticket_data.write(int.to_bytes(title_version_high, 1)) title_version_low = self.title_version % 256
title_version_low = self.title_version % 256 ticket_data += int.to_bytes(title_version_low, 1)
ticket_data.write(int.to_bytes(title_version_low, 1)) # Permitted titles mask.
# Permitted titles mask. ticket_data += self.permitted_titles
ticket_data.write(self.permitted_titles) # Permit mask.
# Permit mask. ticket_data += self.permit_mask
ticket_data.write(self.permit_mask) # Title Export allowed.
# Title Export allowed. ticket_data += int.to_bytes(self.title_export_allowed, 1)
ticket_data.write(int.to_bytes(self.title_export_allowed, 1)) # Common Key index.
# Common Key index. ticket_data += int.to_bytes(self.common_key_index, 1)
ticket_data.write(int.to_bytes(self.common_key_index, 1)) # Unknown data 2.
# Unknown data 2. ticket_data += self.unknown2
ticket_data.write(self.unknown2) # Content access permissions.
# Content access permissions. ticket_data += self.content_access_permissions
ticket_data.write(self.content_access_permissions) # Padding (always \x00).
# Padding (always \x00). ticket_data += b'\x00\x00'
ticket_data.write(b'\x00\x00') # Iterate over Title Limit objects, write them back into raw data, then add them to the Ticket.
# Iterate over Title Limit objects, write them back into raw data, then add them to the Ticket. for title_limit in range(len(self.title_limits_list)):
for title_limit in range(len(self.title_limits_list)): title_limit_data = b''
title_limit_data = io.BytesIO() # Write all fields from the title limit entry.
# Write all fields from the title limit entry. title_limit_data += int.to_bytes(self.title_limits_list[title_limit].limit_type, 4)
title_limit_data.write(int.to_bytes(self.title_limits_list[title_limit].limit_type, 4)) title_limit_data += int.to_bytes(self.title_limits_list[title_limit].maximum_usage, 4)
title_limit_data.write(int.to_bytes(self.title_limits_list[title_limit].maximum_usage, 4)) # Write the entry to the ticket.
# Seek to the start and write the entry to the Ticket. ticket_data += title_limit_data
title_limit_data.seek(0x0) return ticket_data
ticket_data.write(title_limit_data.read())
title_limit_data.close() def fakesign(self) -> None:
# Set the Ticket attribute of the object to the new raw Ticket. """
ticket_data.seek(0x0) Fakesigns this Ticket for the trucha bug.
ticket_data_raw = ticket_data.read()
# Return the raw TMD for the data contained in the object. This is done by brute-forcing a Ticket body hash starting with 00, causing it to pass signature verification on
return ticket_data_raw 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_title_id(self) -> str: def get_title_id(self) -> str:
""" """

View File

@@ -1,4 +1,4 @@
# "title.py" from libWiiPy by NinjaCheetah & Contributors # "title/title.py" from libWiiPy by NinjaCheetah & Contributors
# https://github.com/NinjaCheetah/libWiiPy # https://github.com/NinjaCheetah/libWiiPy
# #
# See https://wiibrew.org/wiki/Title for details about how titles are formatted # See https://wiibrew.org/wiki/Title for details about how titles are formatted
@@ -78,10 +78,9 @@ class Title:
# Dump the Ticket and set it in the WAD. # Dump the Ticket and set it in the WAD.
self.wad.set_ticket_data(self.ticket.dump()) self.wad.set_ticket_data(self.ticket.dump())
# Dump the ContentRegion and set it in the WAD. # Dump the ContentRegion and set it in the WAD.
self.wad.set_content_data(self.content.dump()) content_data, content_size = self.content.dump()
# Dump the WAD with the new regions back into raw data and return it. self.wad.set_content_data(content_data, content_size)
wad_data = self.wad.dump() return self.wad.dump()
return wad_data
def load_tmd(self, tmd: bytes) -> None: def load_tmd(self, tmd: bytes) -> None:
""" """
@@ -235,3 +234,18 @@ class Title:
""" """
# Load the decrypted content. # Load the decrypted content.
self.content.load_content(dec_content, index, self.ticket.get_title_key()) 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()

View File

@@ -1,13 +1,14 @@
# "tmd.py" from libWiiPy by NinjaCheetah & Contributors # "title/tmd.py" from libWiiPy by NinjaCheetah & Contributors
# https://github.com/NinjaCheetah/libWiiPy # https://github.com/NinjaCheetah/libWiiPy
# #
# See https://wiibrew.org/wiki/Title_metadata for details about the TMD format # See https://wiibrew.org/wiki/Title_metadata for details about the TMD format
import io import io
import binascii import binascii
import hashlib
import struct import struct
from typing import List from typing import List
from .types import ContentRecord from ..types import _ContentRecord
class TMD: class TMD:
@@ -31,12 +32,12 @@ class TMD:
""" """
def __init__(self): def __init__(self):
self.blob_header: bytes = b'' self.blob_header: bytes = b''
self.sig_type: int = 0 self.signature_type: int = 0
self.sig: bytes = b'' self.signature: bytes = b''
self.issuer: bytes = b'' # Follows the format "Root-CA%08x-CP%08x" self.issuer: bytes = b'' # Follows the format "Root-CA%08x-CP%08x"
self.tmd_version: int = 0 # This seems to always be 0 no matter what? self.tmd_version: int = 0 # This seems to always be 0 no matter what?
self.ca_crl_version: int = 0 self.ca_crl_version: int = 0 # Certificate Authority Certificate Revocation List version
self.signer_crl_version: int = 0 self.signer_crl_version: int = 0 # Certificate Policy Certificate Revocation List version
self.vwii: int = 0 # Whether the title is for the vWii. 0 = No, 1 = Yes self.vwii: int = 0 # Whether the title is for the vWii. 0 = No, 1 = Yes
self.ios_tid: str = "" # The Title ID of the IOS version the associated title runs on. self.ios_tid: str = "" # The Title ID of the IOS version the associated title runs on.
self.ios_version: int = 0 # The IOS version the associated title runs on. self.ios_version: int = 0 # The IOS version the associated title runs on.
@@ -44,17 +45,21 @@ class TMD:
self.content_type: str = "" # The type of content contained within the associated title. self.content_type: str = "" # The type of content contained within the associated title.
self.group_id: int = 0 # The ID of the publisher of the associated title. self.group_id: int = 0 # The ID of the publisher of the associated title.
self.region: int = 0 # The ID of the region of the associated title. self.region: int = 0 # The ID of the region of the associated title.
self.ratings: bytes = b'' 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.ipc_mask: bytes = b''
self.reserved2: bytes = b'' # Other "Reserved" data from WiiBrew.
self.access_rights: bytes = b'' self.access_rights: bytes = b''
self.title_version: int = 0 # The version of the associated title. self.title_version: int = 0 # The version of the associated title.
self.title_version_converted: int = 0 # The title version in vX.X format.
self.num_contents: int = 0 # The number of contents contained in the associated title. self.num_contents: int = 0 # The number of contents contained in the associated title.
self.boot_index: int = 0 self.boot_index: int = 0 # The content index that contains the bootable executable.
self.content_records: List[ContentRecord] = [] self.minor_version: int = 0 # Minor version (unused typically).
self.content_records: List[_ContentRecord] = []
def load(self, tmd: bytes) -> None: def load(self, tmd: bytes) -> None:
""" """
Loads raw TMD data and sets all attributes of the WAD object. This allows for manipulating an already Loads raw TMD data and sets all attributes of the TMD object. This allows for manipulating an already
existing TMD. existing TMD.
Parameters Parameters
@@ -66,18 +71,22 @@ class TMD:
# ==================================================================================== # ====================================================================================
# Parses each of the keys contained in the TMD. # Parses each of the keys contained in the TMD.
# ==================================================================================== # ====================================================================================
# Signature type.
tmd_data.seek(0x0) tmd_data.seek(0x0)
self.blob_header = tmd_data.read(320) self.signature_type = tmd_data.read(4)
# Signature data.
tmd_data.seek(0x04)
self.signature = tmd_data.read(256)
# Signing certificate issuer. # Signing certificate issuer.
tmd_data.seek(0x140) tmd_data.seek(0x140)
self.issuer = tmd_data.read(64) self.issuer = tmd_data.read(64)
# TMD version, seems to usually be 0, but I've seen references to other numbers. # TMD version, seems to usually be 0, but I've seen references to other numbers.
tmd_data.seek(0x180) tmd_data.seek(0x180)
self.tmd_version = int.from_bytes(tmd_data.read(1)) self.tmd_version = int.from_bytes(tmd_data.read(1))
# Root certificate crl version. # Certificate Authority CRL version.
tmd_data.seek(0x181) tmd_data.seek(0x181)
self.ca_crl_version = int.from_bytes(tmd_data.read(1)) self.ca_crl_version = int.from_bytes(tmd_data.read(1))
# Signer crl version. # Certificate Policy CRL version.
tmd_data.seek(0x182) tmd_data.seek(0x182)
self.signer_crl_version = int.from_bytes(tmd_data.read(1)) self.signer_crl_version = int.from_bytes(tmd_data.read(1))
# If this is a vWii title or not. # If this is a vWii title or not.
@@ -103,123 +112,157 @@ class TMD:
# Publisher of the title. # Publisher of the title.
tmd_data.seek(0x198) tmd_data.seek(0x198)
self.group_id = int.from_bytes(tmd_data.read(2)) self.group_id = int.from_bytes(tmd_data.read(2))
# Region of the title, 0 = JAP, 1 = USA, 2 = EUR, 3 = NONE, 4 = KOR. # Region of the title, 0 = JAP, 1 = USA, 2 = EUR, 3 = WORLD, 4 = KOR.
tmd_data.seek(0x19C) tmd_data.seek(0x19C)
region_hex = tmd_data.read(2) region_hex = tmd_data.read(2)
self.region = int.from_bytes(region_hex) self.region = int.from_bytes(region_hex)
# Likely the localized content rating for the title. (ESRB, CERO, PEGI, etc.) # Content rating of the title for parental controls. Likely based on ESRB, CERO, PEGI, etc. rating.
tmd_data.seek(0x19E) tmd_data.seek(0x19E)
self.ratings = tmd_data.read(16) self.ratings = tmd_data.read(16)
# "Reserved" data 1.
tmd_data.seek(0x1AE)
self.reserved1 = tmd_data.read(12)
# IPC mask. # IPC mask.
tmd_data.seek(0x1BA) tmd_data.seek(0x1BA)
self.ipc_mask = tmd_data.read(12) self.ipc_mask = tmd_data.read(12)
# "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 access and AHBPROT.
tmd_data.seek(0x1D8) tmd_data.seek(0x1D8)
self.access_rights = tmd_data.read(4) self.access_rights = tmd_data.read(4)
# Calculate the version number by multiplying 0x1DC by 256 and adding 0x1DD. # Version number straight from the TMD.
tmd_data.seek(0x1DC)
self.title_version = int.from_bytes(tmd_data.read(2))
# Calculate the converted version number by multiplying 0x1DC by 256 and adding 0x1DD.
tmd_data.seek(0x1DC) tmd_data.seek(0x1DC)
title_version_high = int.from_bytes(tmd_data.read(1)) * 256 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)) title_version_low = int.from_bytes(tmd_data.read(1))
self.title_version = title_version_high + title_version_low self.title_version_converted = title_version_high + title_version_low
# The number of contents listed in the TMD. # The number of contents listed in the TMD.
tmd_data.seek(0x1DE) tmd_data.seek(0x1DE)
self.num_contents = int.from_bytes(tmd_data.read(2)) self.num_contents = int.from_bytes(tmd_data.read(2))
# Content index in content list that contains the boot file. # The content index that contains the bootable executable.
tmd_data.seek(0x1E0) tmd_data.seek(0x1E0)
self.boot_index = int.from_bytes(tmd_data.read(2)) 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. # Get content records for the number of contents in num_contents.
self.content_records = [] self.content_records = []
for content in range(0, self.num_contents): for content in range(0, self.num_contents):
tmd_data.seek(0x1E4 + (36 * content)) tmd_data.seek(0x1E4 + (36 * content))
content_record_hdr = struct.unpack(">LHH4x4s20s", tmd_data.read(36)) content_record_hdr = struct.unpack(">LHH4x4s20s", tmd_data.read(36))
self.content_records.append( self.content_records.append(
ContentRecord(int(content_record_hdr[0]), int(content_record_hdr[1]), _ContentRecord(int(content_record_hdr[0]), int(content_record_hdr[1]),
int(content_record_hdr[2]), int.from_bytes(content_record_hdr[3]), int(content_record_hdr[2]), int.from_bytes(content_record_hdr[3]),
binascii.hexlify(content_record_hdr[4]))) binascii.hexlify(content_record_hdr[4])))
def dump(self) -> bytes: 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, Dumps the TMD object back into bytes.
and triggers load() again to ensure that the raw data and object match.
Returns Returns
------- -------
bytes bytes
The full TMD file as bytes. The full TMD file as bytes.
""" """
# Open the stream and begin writing to it. tmd_data = b''
with io.BytesIO() as tmd_data: # Signature type.
# Signed blob header. tmd_data += self.signature_type
tmd_data.write(self.blob_header) # Signature data.
# Signing certificate issuer. tmd_data += self.signature
tmd_data.write(self.issuer) # Padding to 64 bytes.
# TMD version. tmd_data += b'\x00' * 60
tmd_data.write(int.to_bytes(self.tmd_version, 1)) # Signing certificate issuer.
# Root certificate crl version. tmd_data += self.issuer
tmd_data.write(int.to_bytes(self.ca_crl_version, 1)) # TMD version.
# Signer crl version. tmd_data += int.to_bytes(self.tmd_version, 1)
tmd_data.write(int.to_bytes(self.signer_crl_version, 1)) # Certificate Authority CRL version.
# If this is a vWii title or not. tmd_data += int.to_bytes(self.ca_crl_version, 1)
tmd_data.write(int.to_bytes(self.vwii, 1)) # Certificate Policy CRL version.
# IOS Title ID. tmd_data += int.to_bytes(self.signer_crl_version, 1)
tmd_data.write(binascii.unhexlify(self.ios_tid)) # If this is a vWii title or not.
# Title's Title ID. tmd_data += int.to_bytes(self.vwii, 1)
tmd_data.write(binascii.unhexlify(self.title_id)) # IOS Title ID.
# Content type. tmd_data += binascii.unhexlify(self.ios_tid)
tmd_data.write(binascii.unhexlify(self.content_type)) # Title's Title ID.
# Group ID. tmd_data += binascii.unhexlify(self.title_id)
tmd_data.write(int.to_bytes(self.group_id, 2)) # Content type.
# 2 bytes of zero for reasons. tmd_data += binascii.unhexlify(self.content_type)
tmd_data.write(b'\x00\x00') # Group ID.
# Region. tmd_data += int.to_bytes(self.group_id, 2)
tmd_data.write(int.to_bytes(self.region, 2)) # 2 bytes of zero for reasons.
# Ratings. tmd_data += b'\x00\x00'
tmd_data.write(self.ratings) # Region.
# Reserved (all \x00). tmd_data += int.to_bytes(self.region, 2)
tmd_data.write(b'\x00' * 12) # Parental Controls Ratings.
# IPC mask. tmd_data += self.ratings
tmd_data.write(self.ipc_mask) # "Reserved" 1.
# Reserved (all \x00). tmd_data += self.reserved1
tmd_data.write(b'\x00' * 18) # IPC mask.
# Access rights. tmd_data += self.ipc_mask
tmd_data.write(self.access_rights) # "Reserved" 2.
# Title version. tmd_data += self.reserved2
title_version_high = round(self.title_version / 256) # Access rights.
tmd_data.write(int.to_bytes(title_version_high, 1)) tmd_data += self.access_rights
title_version_low = self.title_version % 256 # Title version.
tmd_data.write(int.to_bytes(title_version_low, 1)) tmd_data += int.to_bytes(self.title_version, 2)
# Number of contents. # Number of contents.
tmd_data.write(int.to_bytes(self.num_contents, 2)) tmd_data += int.to_bytes(self.num_contents, 2)
# Boot index. # Boot index.
tmd_data.write(int.to_bytes(self.boot_index, 2)) tmd_data += int.to_bytes(self.boot_index, 2)
# Minor version. Unused so write \x00. # Minor version.
tmd_data.write(b'\x00\x00') 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. # Iterate over content records, write them back into raw data, then add them to the TMD.
for content_record in range(self.num_contents): for content_record in range(self.num_contents):
content_data = io.BytesIO() content_data = b''
# Write all fields from the content record. # Write all fields from the content record.
content_data.write(int.to_bytes(self.content_records[content_record].content_id, 4)) content_data += int.to_bytes(self.content_records[content_record].content_id, 4)
content_data.write(int.to_bytes(self.content_records[content_record].index, 2)) content_data += int.to_bytes(self.content_records[content_record].index, 2)
content_data.write(int.to_bytes(self.content_records[content_record].content_type, 2)) content_data += int.to_bytes(self.content_records[content_record].content_type, 2)
content_data.write(int.to_bytes(self.content_records[content_record].content_size, 8)) content_data += int.to_bytes(self.content_records[content_record].content_size, 8)
content_data.write(binascii.unhexlify(self.content_records[content_record].content_hash)) content_data += binascii.unhexlify(self.content_records[content_record].content_hash)
# Seek to the start and write the record to the TMD. # Write the record to the TMD.
content_data.seek(0x0) tmd_data += content_data
tmd_data.write(content_data.read()) return tmd_data
content_data.close()
# Set the TMD attribute of the object to the new raw TMD. def fakesign(self) -> None:
tmd_data.seek(0x0) """
tmd_data_raw = tmd_data.read() Fakesigns this TMD for the trucha bug.
# Return the raw TMD for the data contained in the object.
return tmd_data_raw 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_title_region(self) -> str: def get_title_region(self) -> str:
""" """
Gets the region of the TMD's associated title. Gets the region of the TMD's associated title.
Can be one of several possible values: Can be one of several possible values:
'JAP', 'USA', 'EUR', 'NONE', or 'KOR'. 'JAP', 'USA', 'EUR', 'WORLD', or 'KOR'.
Returns Returns
------- -------
@@ -234,7 +277,7 @@ class TMD:
case 2: case 2:
return "EUR" return "EUR"
case 3: case 3:
return "NONE" return "WORLD"
case 4: case 4:
return "KOR" return "KOR"
@@ -309,7 +352,7 @@ class TMD:
case _: case _:
return "Unknown" return "Unknown"
def get_content_record(self, record) -> ContentRecord: def get_content_record(self, record) -> _ContentRecord:
""" """
Gets the content record at the specified index. Gets the content record at the specified index.
@@ -320,7 +363,7 @@ class TMD:
Returns Returns
------- -------
ContentRecord _ContentRecord
A ContentRecord object containing the data in the content record. A ContentRecord object containing the data in the content record.
""" """
if record < self.num_contents: if record < self.num_contents:

View File

@@ -1,11 +1,11 @@
# "wad.py" from libWiiPy by NinjaCheetah & Contributors # "title/wad.py" from libWiiPy by NinjaCheetah & Contributors
# https://github.com/NinjaCheetah/libWiiPy # https://github.com/NinjaCheetah/libWiiPy
# #
# See https://wiibrew.org/wiki/WAD_files for details about the WAD format # See https://wiibrew.org/wiki/WAD_files for details about the WAD format
import io import io
import binascii import binascii
from .shared import align_value, pad_bytes_stream from ..shared import _align_value, _pad_bytes
class WAD: class WAD:
@@ -15,7 +15,7 @@ class WAD:
Attributes Attributes
---------- ----------
wad_type : str wad_type : str
The type of WAD, either ib for boot2 or Is for normal installable WADs. libWiiPy only supports Is currently. The type of WAD, either ib for boot2 or Is for normal installable WADs.
wad_cert_size : int wad_cert_size : int
The size of the WAD's certificate. The size of the WAD's certificate.
wad_crl_size : int wad_crl_size : int
@@ -49,7 +49,7 @@ class WAD:
self.wad_content_data: bytes = b'' self.wad_content_data: bytes = b''
self.wad_meta_data: bytes = b'' self.wad_meta_data: bytes = b''
def load(self, wad_data) -> None: def load(self, wad_data: bytes) -> None:
""" """
Loads raw WAD data and sets all attributes of the WAD object. This allows for manipulating an already Loads raw WAD data and sets all attributes of the WAD object. This allows for manipulating an already
existing WAD file. existing WAD file.
@@ -57,17 +57,17 @@ class WAD:
Parameters Parameters
---------- ----------
wad_data : bytes wad_data : bytes
The data for the WAD you wish to load. The data for the WAD file to load.
""" """
with io.BytesIO(wad_data) as wad_data: with io.BytesIO(wad_data) as wad_data:
# Read the first 8 bytes of the file to ensure that it's a WAD. This will currently reject boot2 WADs, but # Read the first 8 bytes of the file to ensure that it's a WAD. Has two possible valid values for the two
# this tool cannot handle them correctly right now anyway. # different types of WADs that might be encountered.
wad_data.seek(0x0) wad_data.seek(0x0)
wad_magic_bin = wad_data.read(8) wad_magic_bin = wad_data.read(8)
wad_magic_hex = binascii.hexlify(wad_magic_bin) wad_magic_hex = binascii.hexlify(wad_magic_bin)
wad_magic = str(wad_magic_hex.decode()) wad_magic = str(wad_magic_hex.decode())
if wad_magic != "0000002049730000" and wad_magic != "0000002069620000": if wad_magic != "0000002049730000" and wad_magic != "0000002069620000":
raise TypeError("This does not appear to be a valid WAD file.") raise TypeError("This is not a valid WAD file!")
# ==================================================================================== # ====================================================================================
# Get the sizes of each data region contained within the WAD. # Get the sizes of each data region contained within the WAD.
# ==================================================================================== # ====================================================================================
@@ -91,9 +91,11 @@ class WAD:
# WAD TMD size. # WAD TMD size.
wad_data.seek(0x14) wad_data.seek(0x14)
self.wad_tmd_size = int(binascii.hexlify(wad_data.read(4)), 16) self.wad_tmd_size = int(binascii.hexlify(wad_data.read(4)), 16)
# WAD content size. # WAD content size. This needs to be rounded now, because with some titles (primarily IOS?), there can be
# extra bytes past the listed end of the content that is needed for decryption.
wad_data.seek(0x18) wad_data.seek(0x18)
self.wad_content_size = int(binascii.hexlify(wad_data.read(4)), 16) self.wad_content_size = int(binascii.hexlify(wad_data.read(4)), 16)
self.wad_content_size = _align_value(self.wad_content_size, 16)
# Time/build stamp for the title contained in the WAD. # Time/build stamp for the title contained in the WAD.
wad_data.seek(0x1c) wad_data.seek(0x1c)
self.wad_meta_size = int(binascii.hexlify(wad_data.read(4)), 16) self.wad_meta_size = int(binascii.hexlify(wad_data.read(4)), 16)
@@ -102,12 +104,12 @@ class WAD:
# ==================================================================================== # ====================================================================================
wad_cert_offset = self.wad_hdr_size wad_cert_offset = self.wad_hdr_size
# crl isn't ever used, however an entry for its size exists in the header, so its calculated just in case. # crl isn't ever used, however an entry for its size exists in the header, so its calculated just in case.
wad_crl_offset = align_value(wad_cert_offset + self.wad_cert_size) wad_crl_offset = _align_value(wad_cert_offset + self.wad_cert_size)
wad_tik_offset = align_value(wad_crl_offset + self.wad_crl_size) wad_tik_offset = _align_value(wad_crl_offset + self.wad_crl_size)
wad_tmd_offset = align_value(wad_tik_offset + self.wad_tik_size) wad_tmd_offset = _align_value(wad_tik_offset + self.wad_tik_size)
# meta isn't guaranteed to be used, but some older SDK titles use it, and not reading it breaks things. # meta isn't guaranteed to be used, but some older SDK titles use it, and not reading it breaks things.
wad_meta_offset = align_value(wad_tmd_offset + self.wad_tmd_size) wad_meta_offset = _align_value(wad_tmd_offset + self.wad_tmd_size)
wad_content_offset = align_value(wad_meta_offset + self.wad_meta_size) wad_content_offset = _align_value(wad_meta_offset + self.wad_meta_size)
# ==================================================================================== # ====================================================================================
# Load data for each WAD section based on the previously calculated offsets. # Load data for each WAD section based on the previously calculated offsets.
# ==================================================================================== # ====================================================================================
@@ -140,50 +142,45 @@ class WAD:
bytes bytes
The full WAD file as bytes. The full WAD file as bytes.
""" """
# Open the stream and begin writing data to it. wad_data = b''
with io.BytesIO() as wad_data: # Lead-in data.
# Lead-in data. wad_data += b'\x00\x00\x00\x20'
wad_data.write(b'\x00\x00\x00\x20') # WAD type.
# WAD type. wad_data += str.encode(self.wad_type)
wad_data.write(str.encode(self.wad_type)) # WAD version.
# WAD version. wad_data += self.wad_version
wad_data.write(self.wad_version) # WAD cert size.
# WAD cert size. wad_data += int.to_bytes(self.wad_cert_size, 4)
wad_data.write(int.to_bytes(self.wad_cert_size, 4)) # WAD crl size.
# WAD crl size. wad_data += int.to_bytes(self.wad_crl_size, 4)
wad_data.write(int.to_bytes(self.wad_crl_size, 4)) # WAD ticket size.
# WAD ticket size. wad_data += int.to_bytes(self.wad_tik_size, 4)
wad_data.write(int.to_bytes(self.wad_tik_size, 4)) # WAD TMD size.
# WAD TMD size. wad_data += int.to_bytes(self.wad_tmd_size, 4)
wad_data.write(int.to_bytes(self.wad_tmd_size, 4)) # WAD content size.
# WAD content size. wad_data += int.to_bytes(self.wad_content_size, 4)
wad_data.write(int.to_bytes(self.wad_content_size, 4)) # WAD meta size.
# WAD meta size. wad_data += int.to_bytes(self.wad_meta_size, 4)
wad_data.write(int.to_bytes(self.wad_meta_size, 4)) wad_data = _pad_bytes(wad_data)
wad_data = pad_bytes_stream(wad_data) # Retrieve the cert data and write it out.
# Retrieve the cert data and write it out. wad_data += self.get_cert_data()
wad_data.write(self.get_cert_data()) wad_data = _pad_bytes(wad_data)
wad_data = pad_bytes_stream(wad_data) # Retrieve the crl data and write it out.
# Retrieve the crl data and write it out. wad_data += self.get_crl_data()
wad_data.write(self.get_crl_data()) wad_data = _pad_bytes(wad_data)
wad_data = pad_bytes_stream(wad_data) # Retrieve the ticket data and write it out.
# Retrieve the ticket data and write it out. wad_data += self.get_ticket_data()
wad_data.write(self.get_ticket_data()) wad_data = _pad_bytes(wad_data)
wad_data = pad_bytes_stream(wad_data) # Retrieve the TMD data and write it out.
# Retrieve the TMD data and write it out. wad_data += self.get_tmd_data()
wad_data.write(self.get_tmd_data()) wad_data = _pad_bytes(wad_data)
wad_data = pad_bytes_stream(wad_data) # Retrieve the meta/footer data and write it out.
# Retrieve the meta/footer data and write it out. wad_data += self.get_meta_data()
wad_data.write(self.get_meta_data()) wad_data = _pad_bytes(wad_data)
wad_data = pad_bytes_stream(wad_data) # Retrieve the content data and write it out.
# Retrieve the content data and write it out. wad_data += self.get_content_data()
wad_data.write(self.get_content_data()) wad_data = _pad_bytes(wad_data)
wad_data = pad_bytes_stream(wad_data) return wad_data
# Seek to the beginning and save this as the WAD data for the object.
wad_data.seek(0x0)
wad_data_raw = wad_data.read()
# Return the raw WAD file for the data contained in the object.
return wad_data_raw
def get_wad_type(self) -> str: def get_wad_type(self) -> str:
""" """
@@ -314,7 +311,7 @@ class WAD:
# Calculate the size of the new Ticket data. # Calculate the size of the new Ticket data.
self.wad_tik_size = len(tik_data) self.wad_tik_size = len(tik_data)
def set_content_data(self, content_data) -> None: def set_content_data(self, content_data, size: int = None) -> None:
""" """
Sets the content data of the WAD. Also calculates the new size. Sets the content data of the WAD. Also calculates the new size.
@@ -322,10 +319,15 @@ class WAD:
---------- ----------
content_data : bytes content_data : bytes
The new content data. The new content data.
size : int, option
The size of the new content data.
""" """
self.wad_content_data = content_data self.wad_content_data = content_data
# Calculate the size of the new content data. # Calculate the size of the new content data, if one wasn't supplied.
self.wad_content_size = len(content_data) if size is None:
self.wad_content_size = len(content_data)
else:
self.wad_content_size = size
def set_meta_data(self, meta_data) -> None: def set_meta_data(self, meta_data) -> None:
""" """

View File

@@ -1,11 +1,10 @@
# "types.py" from libWiiPy by NinjaCheetah & Contributors # "types.py" from libWiiPy by NinjaCheetah & Contributors
# https://github.com/NinjaCheetah/libWiiPy # https://github.com/NinjaCheetah/libWiiPy
from dataclasses import dataclass from dataclasses import dataclass
@dataclass @dataclass
class ContentRecord: class _ContentRecord:
""" """
A content record object that contains the details of a content contained in a title. This information must match A content record object that contains the details of a content contained in a title. This information must match
the content stored at the index in the record, or else the content will not decrypt properly, as the hash of the the content stored at the index in the record, or else the content will not decrypt properly, as the hash of the
@@ -14,39 +13,18 @@ class ContentRecord:
Attributes Attributes
---------- ----------
content_id : int content_id : int
ID of the content. The unique ID of the content.
index : int index : int
Index of the content in the list of contents. The index of this content in the content records.
content_type : int content_type : int
The type of the content. The type of the content.
content_size : int content_size : int
The size of the content. The size of the content when decrypted.
content_hash content_hash
The SHA-1 hash of the decrypted content. The SHA-1 hash of the decrypted content.
""" """
content_id: int # The unique ID of the content. content_id: int
index: int # The index of this content in the content record. index: int
content_type: int # Type of content, possible values of: 0x0001: Normal, 0x4001: DLC, 0x8001: Shared. content_type: int # Type of content, possible values of: 0x0001: Normal, 0x4001: DLC, 0x8001: Shared.
content_size: int # Size of the content when decrypted. content_size: int
content_hash: bytes # SHA-1 hash of the content when decrypted. content_hash: bytes
@dataclass
class TitleLimit:
"""
A TitleLimit object that contains the type of restriction and the limit. The limit type can be one of the following:
0 = None, 1 = Time Limit, 3 = None, or 4 = Launch Count. The maximum usage is then either the time in minutes the
title can be played or the maximum number of launches allowed for that title, based on the type of limit applied.
Attributes
----------
limit_type : int
The type of play limit applied.
maximum_usage : int
The maximum value for the type of play limit applied.
"""
# The type of play limit applied.
# 0 = None, 1 = Time Limit, 3 = None, 4 = Launch Count
limit_type: int
# The maximum value of the limit applied.
maximum_usage: int

11
tests/__init.py__.py Normal file
View File

@@ -0,0 +1,11 @@
# "__init__.py" from libWiiPy by NinjaCheetah & Contributors
# https://github.com/NinjaCheetah/libWiiPy
#
# Complete set of tests to be run.
import unittest
from test_commonkeys import TestCommonKeys
if __name__ == '__main__':
unittest.main()

21
tests/test_commonkeys.py Normal file
View File

@@ -0,0 +1,21 @@
# "test_commonkeys.py" from libWiiPy by NinjaCheetah & Contributors
# https://github.com/NinjaCheetah/libWiiPy
import unittest
from libWiiPy import title
class TestCommonKeys(unittest.TestCase):
def test_common(self):
self.assertEqual(title.get_common_key(0), b'\xeb\xe4*"^\x85\x93\xe4H\xd9\xc5Es\x81\xaa\xf7')
def test_korean(self):
self.assertEqual(title.get_common_key(1), b'c\xb8+\xb4\xf4aN.\x13\xf2\xfe\xfb\xbaL\x9b~')
def test_vwii(self):
self.assertEqual(title.get_common_key(2), b'0\xbf\xc7n|\x19\xaf\xbb#\x1630\xce\xd7\xc2\x8d')
if __name__ == '__main__':
unittest.main()