mirror of
https://github.com/NinjaCheetah/libWiiPy.git
synced 2026-03-05 00:25:29 -05:00
Compare commits
68 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
1b6e0db26d
|
|||
|
9ae059b797
|
|||
|
c604c195d2
|
|||
|
0c2e13f18a
|
|||
|
7fed039fdc
|
|||
|
0d306076a2
|
|||
|
a1773b9a02
|
|||
|
7c2f0fb21f
|
|||
|
0edd4fa6bb
|
|||
|
e163d34f0b
|
|||
|
9fb0fdbc17
|
|||
|
1ae649afac
|
|||
|
b782e5dea5
|
|||
|
894aa3a04b
|
|||
|
152a13fbe2
|
|||
|
72a8b9b6a6
|
|||
|
128f4a9303
|
|||
|
bab777b8b9
|
|||
|
fb87c2c58c
|
|||
|
6220821a2f
|
|||
|
580ba8526f
|
|||
|
7e308a35eb
|
|||
|
194b65c6d6
|
|||
|
cfd5abac7e
|
|||
|
7edf764768
|
|||
| 544e65a109 | |||
| bcbdd284e9 | |||
|
415af7b8b8
|
|||
|
f81398e854
|
|||
|
60975dc62d
|
|||
|
40e4459893
|
|||
|
5c56eabe9f
|
|||
|
9d26ff74ff
|
|||
|
18b54af091
|
|||
|
2d67f982dc
|
|||
|
d6e6352d0a
|
|||
|
7daba7ec86
|
|||
|
930e09828e
|
|||
|
a5ce7e9cd1
|
|||
|
76b773ee36
|
|||
|
817a2c9ac5
|
|||
|
102da808e6
|
|||
|
f7f67d3414
|
|||
|
39eecec864
|
|||
|
5f4fa8827c
|
|||
|
e70b9570de
|
|||
|
4f96e1b0d9
|
|||
|
bcd61b8a37
|
|||
|
a56fa6e051
|
|||
|
535de7f228
|
|||
|
adac67b158
|
|||
|
f96da98d23
|
|||
|
c5abf16f67
|
|||
|
45638769a9
|
|||
|
1f731bbc81
|
|||
|
9bfb44771e
|
|||
|
82f4e7dcc4
|
|||
| c4847365ab | |||
|
e00fc376c6
|
|||
|
04fa9034a0
|
|||
|
df1ed559ef
|
|||
|
53e098ca9b
|
|||
|
da2d6b0003
|
|||
|
6575dd37f7
|
|||
|
75510ed2b9
|
|||
| 53f798e36a | |||
|
0861c20100
|
|||
|
2d64f7961e
|
64
.github/workflows/sphinx-docs.yml
vendored
Normal file
64
.github/workflows/sphinx-docs.yml
vendored
Normal 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
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -165,6 +165,7 @@ cython_debug/
|
||||
*.tmd
|
||||
*.wad
|
||||
*.arc
|
||||
*.ash
|
||||
out_prod/
|
||||
remakewad.pl
|
||||
|
||||
|
||||
24
README.md
24
README.md
@@ -1,20 +1,28 @@
|
||||

|
||||

|
||||
# libWiiPy
|
||||
libWiiPy is a modern Python 3 library for handling the various files and formats found on the Wii. It aims to be simple to use, well maintained, and offer as many features as reasonably possible in one library, so that a newly-written Python program could reasonably do 100% of its Wii-related work with just one library. It also aims to be fully cross-platform, so that any tools written with it can also be cross-platform.
|
||||
libWiiPy is a modern Python 3 library for handling the various files and formats found on the Wii. It aims to be simple to use, well maintained, and offer as many features as reasonably possible in one library, so that a newly-written Python program could do 100% of its Wii-related work with just one library. It also aims to be fully cross-platform, so that any tools written with it can also be cross-platform.
|
||||
|
||||
libWiiPy is inspired by [libWiiSharp](https://github.com/TheShadowEevee/libWiiSharp), which was originally created by `Leathl` and is now maintained by [@TheShadowEevee](https://github.com/TheShadowEevee). If you're looking for a Wii library that isn't in Python, then go check it out!
|
||||
|
||||
|
||||
# Features
|
||||
This list will expand as libWiiPy is developed, but these features are currently available:
|
||||
- TMD, ticket, and WAD parsing
|
||||
- WAD content extraction, decryption, re-encryption, and packing
|
||||
- TMD and Ticket parsing/editing (`.tmd`, `.tik`)
|
||||
- Title parsing/editing, including content encryption/decryption
|
||||
- WAD file parsing/editing (`.wad`)
|
||||
- Downloading titles from the NUS
|
||||
- Packing and unpacking U8 archives (.app, .arc, .carc, .szs)
|
||||
- Packing and unpacking U8 archives (`.app`, `.arc`)
|
||||
- Decompressing ASH files (`.ash`, both the standard variants and the variants found in My Pokémon Ranch)
|
||||
- IOS patching
|
||||
- NAND-related functionality:
|
||||
- EmuNAND title management (currently requires an existing EmuNAND)
|
||||
- `content.map` parsing/editing
|
||||
- `uid.sys` parsing/editing
|
||||
- Assorted miscellaneous features used to make the other core features possible
|
||||
|
||||
For a more detailed look at what's available in libWiiPy, check out our [API docs](https://ninjacheetah.github.io/libWiiPy).
|
||||
|
||||
# Usage
|
||||
A wiki, and in the future a potential documenation site, is being worked on, and can be accessed [here](https://github.com/NinjaCheetah/libWiiPy/wiki). It is currently fairly barebones, but it will be improved in the future.
|
||||
|
||||
The easiest way to get libWiiPy for your project is to install the latest version of the library from PyPI, as shown below.
|
||||
```sh
|
||||
pip install -U libWiiPy
|
||||
@@ -27,6 +35,8 @@ pip install -U git+https://github.com/NinjaCheetah/libWiiPy
|
||||
```
|
||||
Please be aware that because libWiiPy is in a very early state right now, many features may be subject to change, and methods and properties available now have the potential to disappear in the future.
|
||||
|
||||
For more tips on getting started, see our guide [here](https://ninjacheetah.github.io/libWiiPy/usage/installation.html).
|
||||
|
||||
# Building
|
||||
To build this package locally, the steps are quite simple, and should apply to all platforms. Make sure you've set up your `venv` first!
|
||||
|
||||
|
||||
20
docs/Makefile
Normal file
20
docs/Makefile
Normal 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
35
docs/make.bat
Normal 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
BIN
docs/source/banner.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
38
docs/source/conf.py
Normal file
38
docs/source/conf.py
Normal 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
46
docs/source/index.md
Normal 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>
|
||||
28
docs/source/libWiiPy.archive.md
Normal file
28
docs/source/libWiiPy.archive.md
Normal 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:
|
||||
```
|
||||
35
docs/source/libWiiPy.md
Normal file
35
docs/source/libWiiPy.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# libWiiPy package
|
||||
|
||||
## Subpackages
|
||||
|
||||
```{toctree}
|
||||
:maxdepth: 4
|
||||
|
||||
libWiiPy.archive
|
||||
libWiiPy.nand
|
||||
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:
|
||||
```
|
||||
27
docs/source/libWiiPy.nand.md
Normal file
27
docs/source/libWiiPy.nand.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# libWiiPy.nand package
|
||||
|
||||
## Submodules
|
||||
|
||||
### libWiiPy.nand.emunand module
|
||||
```{eval-rst}
|
||||
.. automodule:: libWiiPy.nand.emunand
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
```
|
||||
|
||||
### libWiiPy.nand.setting module
|
||||
```{eval-rst}
|
||||
.. automodule:: libWiiPy.nand.setting
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
```
|
||||
|
||||
### libWiiPy.nand.sys module
|
||||
```{eval-rst}
|
||||
.. automodule:: libWiiPy.nand.sys
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
```
|
||||
91
docs/source/libWiiPy.title.md
Normal file
91
docs/source/libWiiPy.title.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# 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.iospatcher module
|
||||
```{eval-rst}
|
||||
.. automodule:: libWiiPy.title.iospatcher
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
```
|
||||
|
||||
### libWiiPy.title.nus module
|
||||
```{eval-rst}
|
||||
.. automodule:: libWiiPy.title.nus
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
```
|
||||
|
||||
### libWiiPy.title.ticket module
|
||||
```{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.util module
|
||||
```{eval-rst}
|
||||
.. automodule:: libWiiPy.title.util
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
```
|
||||
|
||||
### libWiiPy.title.wad module
|
||||
```{eval-rst}
|
||||
.. automodule:: libWiiPy.title.wad
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
```
|
||||
|
||||
## Module contents
|
||||
```{eval-rst}
|
||||
.. automodule:: libWiiPy.title
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
```
|
||||
7
docs/source/modules.md
Normal file
7
docs/source/modules.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Modules Overview
|
||||
|
||||
```{toctree}
|
||||
:maxdepth: 4
|
||||
|
||||
libWiiPy
|
||||
```
|
||||
131
docs/source/titles/extracting-titles.md
Normal file
131
docs/source/titles/extracting-titles.md
Normal 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.
|
||||
5
docs/source/titles/nus-downloading.md
Normal file
5
docs/source/titles/nus-downloading.md
Normal 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.
|
||||
34
docs/source/titles/title-anatomy.md
Normal file
34
docs/source/titles/title-anatomy.md
Normal 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.
|
||||
5
docs/source/titles/title-module.md
Normal file
5
docs/source/titles/title-module.md
Normal 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.
|
||||
42
docs/source/usage/getting-started.md
Normal file
42
docs/source/usage/getting-started.md
Normal 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>
|
||||
20
docs/source/usage/installation.md
Normal file
20
docs/source/usage/installation.md
Normal 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.
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "libWiiPy"
|
||||
version = "0.3.1"
|
||||
version = "0.5.2"
|
||||
authors = [
|
||||
{ name="NinjaCheetah", email="ninjacheetah@ncxprogramming.com" },
|
||||
{ name="Lillian Skinner", email="lillian@randommeaninglesscharacters.com" }
|
||||
@@ -9,17 +9,28 @@ description = "A modern Python library for handling files used by the Wii"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
classifiers = [
|
||||
"Programming Language :: Python :: 3",
|
||||
# How mature is this project? Common values are
|
||||
# 3 - Alpha
|
||||
# 4 - Beta
|
||||
# 5 - Production/Stable
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
]
|
||||
dependencies = [
|
||||
"pycryptodome",
|
||||
"requests"
|
||||
]
|
||||
keywords = ["Wii", "wii"]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/NinjaCheetah/libWiiPy"
|
||||
Documentation = "https://ninjacheetah.github.io/libWiiPy/"
|
||||
Repository = "https://github.com/NinjaCheetah/libWiiPy.git"
|
||||
Issues = "https://github.com/NinjaCheetah/libWiiPy/issues"
|
||||
|
||||
[build-system]
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
build
|
||||
pycryptodome
|
||||
requests
|
||||
sphinx
|
||||
sphinx-book-theme
|
||||
myst-parser
|
||||
sphinx-copybutton
|
||||
sphinx-tippy
|
||||
sphinx-design
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
#
|
||||
# These are the essential submodules from libWiiPy that you'd probably want imported by default.
|
||||
|
||||
__all__ = ["archive", "title"]
|
||||
__all__ = ["archive", "nand", "title"]
|
||||
|
||||
from . import archive
|
||||
from . import nand
|
||||
from . import title
|
||||
|
||||
@@ -1,4 +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
233
src/libWiiPy/archive/ash.py
Normal 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
|
||||
@@ -6,17 +6,17 @@
|
||||
import io
|
||||
import os
|
||||
import pathlib
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass as _dataclass
|
||||
from typing import List
|
||||
from ..shared import align_value
|
||||
from ..shared import _align_value, _pad_bytes
|
||||
|
||||
|
||||
@dataclass
|
||||
class U8Node:
|
||||
@_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.
|
||||
directory, and the size of the data. Private class used by functions and methods in the U8 module.
|
||||
|
||||
Attributes
|
||||
----------
|
||||
@@ -44,10 +44,13 @@ class U8Archive:
|
||||
----------
|
||||
"""
|
||||
self.u8_magic = b''
|
||||
self.u8_node_list: List[U8Node] = [] # All the nodes in the header of a U8 file.
|
||||
self.u8_node_list: List[_U8Node] = [] # All the nodes in the header of a U8 file.
|
||||
self.file_name_list: List[str] = []
|
||||
self.file_data_list: List[bytes] = []
|
||||
self.u8_file_structure = dict
|
||||
self.root_node_offset: int = 0
|
||||
self.header_size: int = 0
|
||||
self.data_offset: int = 0
|
||||
self.root_node: _U8Node = _U8Node(0, 0, 0, 0)
|
||||
|
||||
def load(self, u8_data: bytes) -> None:
|
||||
"""
|
||||
@@ -64,29 +67,28 @@ class U8Archive:
|
||||
self.u8_magic = u8_data.read(4)
|
||||
if self.u8_magic != b'\x55\xAA\x38\x2D':
|
||||
raise TypeError("This is not a valid U8 archive!")
|
||||
# The following code is all skipped because these values really don't matter for extraction. They honestly
|
||||
# really only matter to my code when they get calculated and used for packing.
|
||||
|
||||
# Offset of the root node, which will always be 0x20.
|
||||
# root_node_offset = int(binascii.hexlify(u8_data.read(4)), 16)
|
||||
self.root_node_offset = int.from_bytes(u8_data.read(4))
|
||||
# The size of the U8 header.
|
||||
# header_size = int(binascii.hexlify(u8_data.read(4)), 16)
|
||||
self.header_size = int.from_bytes(u8_data.read(4))
|
||||
# The offset of the data, which is root_node_offset + header_size, aligned to 0x10.
|
||||
# data_offset = int(binascii.hexlify(u8_data.read(4)), 16)
|
||||
|
||||
# Seek ahead to the size defined in the root node, because it's the total number of nodes in the file. The
|
||||
# rest of the data in the root node (not that it really matters) will get read when we read the whole list.
|
||||
u8_data.seek(u8_data.tell() + 36)
|
||||
self.data_offset = int.from_bytes(u8_data.read(4))
|
||||
# Seek past 16 bytes of padding, then load the root node.
|
||||
u8_data.seek(u8_data.tell() + 16)
|
||||
root_node_type = int.from_bytes(u8_data.read(1))
|
||||
root_node_name_offset = int.from_bytes(u8_data.read(3))
|
||||
root_node_data_offset = int.from_bytes(u8_data.read(4))
|
||||
root_node_size = int.from_bytes(u8_data.read(4))
|
||||
self.root_node = _U8Node(root_node_type, root_node_name_offset, root_node_data_offset, root_node_size)
|
||||
# Seek back before the root node so that it gets read with all the rest.
|
||||
u8_data.seek(u8_data.tell() - 12)
|
||||
# Iterate over the number of nodes that the root node lists.
|
||||
for node in range(root_node_size):
|
||||
node_type = int.from_bytes(u8_data.read(2))
|
||||
node_name_offset = int.from_bytes(u8_data.read(2))
|
||||
node_type = int.from_bytes(u8_data.read(1))
|
||||
node_name_offset = int.from_bytes(u8_data.read(3))
|
||||
node_data_offset = int.from_bytes(u8_data.read(4))
|
||||
node_size = int.from_bytes(u8_data.read(4))
|
||||
self.u8_node_list.append(U8Node(node_type, node_name_offset, node_data_offset, node_size))
|
||||
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:
|
||||
@@ -120,15 +122,19 @@ class U8Archive:
|
||||
# Add the number of bytes used for each file/folder name in the string table.
|
||||
for file_name in self.file_name_list:
|
||||
header_size += len(file_name) + 1
|
||||
# The initial data offset is equal to the file header (32 bytes) + node data aligned to 16 bytes.
|
||||
data_offset = align_value(header_size + 32, 16)
|
||||
# The initial data offset is equal to the file header (32 bytes) + node data aligned to 64 bytes.
|
||||
data_offset = _align_value(header_size + 32, 64)
|
||||
# Adjust all nodes to place file data in the same order as the nodes. Why isn't it already like this?
|
||||
current_data_offset = data_offset
|
||||
current_name_offset = 0
|
||||
for node in range(len(self.u8_node_list)):
|
||||
if self.u8_node_list[node].type == 0:
|
||||
self.u8_node_list[node].data_offset = current_data_offset
|
||||
current_data_offset += self.u8_node_list[node].size
|
||||
# Begin joining all the U8 archive data into one variable.
|
||||
self.u8_node_list[node].data_offset = _align_value(current_data_offset, 32)
|
||||
current_data_offset += _align_value(self.u8_node_list[node].size, 32)
|
||||
# Calculate the name offsets, including the extra 1 for the NULL byte at the end of each name.
|
||||
self.u8_node_list[node].name_offset = current_name_offset
|
||||
current_name_offset += len(self.file_name_list[node]) + 1
|
||||
# Begin joining all the U8 archive data into bytes.
|
||||
u8_data = b''
|
||||
# Magic number.
|
||||
u8_data += b'\x55\xAA\x38\x2D'
|
||||
@@ -142,19 +148,18 @@ class U8Archive:
|
||||
u8_data += (b'\x00' * 16)
|
||||
# Iterate over all the U8 nodes and dump them.
|
||||
for node in self.u8_node_list:
|
||||
u8_data += int.to_bytes(node.type, 2)
|
||||
u8_data += int.to_bytes(node.name_offset, 2)
|
||||
u8_data += int.to_bytes(node.type, 1)
|
||||
u8_data += int.to_bytes(node.name_offset, 3)
|
||||
u8_data += int.to_bytes(node.data_offset, 4)
|
||||
u8_data += int.to_bytes(node.size, 4)
|
||||
# Iterate over all file names and dump them. All file names are suffixed by a \x00 byte.
|
||||
for file_name in self.file_name_list:
|
||||
u8_data += str.encode(file_name) + b'\x00'
|
||||
# Apply the extra padding we calculated earlier by padding to where the data offset begins.
|
||||
while len(u8_data) < data_offset:
|
||||
u8_data += b'\x00'
|
||||
u8_data = _pad_bytes(u8_data, 64)
|
||||
# Iterate all file data and dump it.
|
||||
for file in self.file_data_list:
|
||||
u8_data += file
|
||||
u8_data += _pad_bytes(file, 32)
|
||||
# Return the U8 archive.
|
||||
return u8_data
|
||||
|
||||
@@ -172,77 +177,71 @@ def extract_u8(u8_data, output_folder) -> None:
|
||||
The path to a new folder to extract the archive to.
|
||||
"""
|
||||
output_folder = pathlib.Path(output_folder)
|
||||
if pathlib.Path.is_dir(output_folder):
|
||||
raise ValueError("Output folder already exists!")
|
||||
os.mkdir(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.
|
||||
# This variable stores the order of directory nodes leading to the current working directory, to make sure that
|
||||
# things get where they belong.
|
||||
parent_dirs = [0]
|
||||
for node in range(len(u8_archive.u8_node_list)):
|
||||
# Code for a directory node. Second check just ensures we ignore the root node.
|
||||
if u8_archive.u8_node_list[node].type == 256 and u8_archive.u8_node_list[node].name_offset != 0:
|
||||
# The size value for a directory node is the position of the last node in this directory, with the root node
|
||||
# counting as node 1.
|
||||
# If the current node is below the end of the current directory, create this directory inside the previous
|
||||
# current directory and make the current.
|
||||
if node + 1 < directory_recursion[-1]:
|
||||
# Code for a directory node (excluding the root node since that already exists).
|
||||
if u8_archive.u8_node_list[node].type == 1 and u8_archive.u8_node_list[node].name_offset != 0:
|
||||
if u8_archive.u8_node_list[node].data_offset == parent_dirs[-1]:
|
||||
current_dir = current_dir.joinpath(u8_archive.file_name_list[node])
|
||||
os.mkdir(current_dir)
|
||||
# If the current node is beyond the end of the current directory, we've followed that path all the way down,
|
||||
# so reset back to the root directory and put our new directory there.
|
||||
elif node + 1 > directory_recursion[-1]:
|
||||
current_dir = output_folder.joinpath(u8_archive.file_name_list[node])
|
||||
os.mkdir(current_dir)
|
||||
# This check is here just in case a directory ever ends with an empty directory and not a file.
|
||||
elif node + 1 == directory_recursion[-1]:
|
||||
current_dir = current_dir.parent
|
||||
directory_recursion.pop()
|
||||
# If the last node for the directory we just processed is new (which is always should be), add it to the
|
||||
# recursion array.
|
||||
if u8_archive.u8_node_list[node].size not in directory_recursion:
|
||||
directory_recursion.append(u8_archive.u8_node_list[node].size)
|
||||
current_dir.mkdir(exist_ok=True)
|
||||
parent_dirs.append(node)
|
||||
else:
|
||||
# Go up until we're back at the correct level.
|
||||
while u8_archive.u8_node_list[node].data_offset != parent_dirs[-1]:
|
||||
parent_dirs.pop()
|
||||
parent_dirs.append(node)
|
||||
current_dir = output_folder
|
||||
# Rebuild current working directory, and make sure all directories in the path exist.
|
||||
for directory in parent_dirs:
|
||||
current_dir = current_dir.joinpath(u8_archive.file_name_list[directory])
|
||||
current_dir.mkdir(exist_ok=True)
|
||||
# Code for a file node.
|
||||
elif u8_archive.u8_node_list[node].type == 0:
|
||||
# Write out the file to the current directory.
|
||||
output_file = open(current_dir.joinpath(u8_archive.file_name_list[node]), "wb")
|
||||
output_file.write(u8_archive.file_data_list[node])
|
||||
output_file.close()
|
||||
# If this file is the final node for the current directory, pop() the recursion array and set the current
|
||||
# directory to the parent of the previous current.
|
||||
if node + 1 in directory_recursion:
|
||||
current_dir = current_dir.parent
|
||||
directory_recursion.pop()
|
||||
# Code for a totally unrecognized node type, which should not happen.
|
||||
elif u8_archive.u8_node_list[node].type != 0 and u8_archive.u8_node_list[node].type != 256:
|
||||
raise ValueError("A node with an invalid type (" + str(u8_archive.u8_node_list[node].type) + ") was"
|
||||
"found!")
|
||||
open(current_dir.joinpath(u8_archive.file_name_list[node]), "wb").write(u8_archive.file_data_list[node])
|
||||
# Handle an invalid node type.
|
||||
elif u8_archive.u8_node_list[node].type != 0 and u8_archive.u8_node_list[node].type != 1:
|
||||
raise ValueError("A node with an invalid type (" + str(u8_archive.u8_node_list[node].type) + ") was found!")
|
||||
|
||||
|
||||
def _pack_u8_dir(u8_archive: U8Archive, current_path, node_count, name_offset):
|
||||
def _pack_u8_dir(u8_archive: U8Archive, current_path, node_count, parent_node):
|
||||
# First, get the list of everything in current path.
|
||||
root_list = os.listdir(current_path)
|
||||
file_list = []
|
||||
dir_list = []
|
||||
# Create separate lists of the files and directories in the current directory so that we can handle the files first.
|
||||
# noinspection PyTypeChecker
|
||||
root_list.sort(key=str.lower)
|
||||
for path in root_list:
|
||||
if os.path.isfile(current_path.joinpath(path)):
|
||||
file_list.append(path)
|
||||
elif os.path.isdir(current_path.joinpath(path)):
|
||||
dir_list.append(path)
|
||||
# noinspection PyTypeChecker
|
||||
file_list.sort(key=str.lower)
|
||||
# noinspection PyTypeChecker
|
||||
dir_list.sort(key=str.lower)
|
||||
# For files, read their data into the file data list, add their name into the file name list, then calculate the
|
||||
# offset for their file name and create a new U8Node() for them.
|
||||
# offset for their file name and create a new U8Node() for them. -1 values are temporary and are set during dumping.
|
||||
for file in file_list:
|
||||
node_count += 1
|
||||
u8_archive.file_name_list.append(file)
|
||||
u8_archive.file_data_list.append(open(current_path.joinpath(file), "rb").read())
|
||||
u8_archive.u8_node_list.append(U8Node(0, name_offset, 0, len(u8_archive.file_data_list[-1])))
|
||||
name_offset = name_offset + len(file) + 1 # Add 1 to accommodate the null byte at the end of the name.
|
||||
u8_archive.u8_node_list.append(_U8Node(0, -1, -1, len(u8_archive.file_data_list[-1])))
|
||||
# For directories, add their name to the file name list, add empty data to the file data list (since they obviously
|
||||
# wouldn't have any), find the total number of files and directories inside the directory to calculate the final
|
||||
# node included in it, then recursively call this function again on that directory to process it.
|
||||
@@ -251,12 +250,11 @@ def _pack_u8_dir(u8_archive: U8Archive, current_path, node_count, name_offset):
|
||||
u8_archive.file_name_list.append(directory)
|
||||
u8_archive.file_data_list.append(b'')
|
||||
max_node = node_count + sum(1 for _ in current_path.joinpath(directory).rglob('*'))
|
||||
u8_archive.u8_node_list.append(U8Node(256, name_offset, 0, max_node))
|
||||
name_offset = name_offset + len(directory) + 1 # Add 1 to accommodate the null byte at the end of the name.
|
||||
u8_archive, node_count, name_offset = _pack_u8_dir(u8_archive, current_path.joinpath(directory), node_count,
|
||||
name_offset)
|
||||
u8_archive.u8_node_list.append(_U8Node(1, -1, parent_node, max_node))
|
||||
u8_archive, node_count = _pack_u8_dir(u8_archive, current_path.joinpath(directory), node_count,
|
||||
u8_archive.u8_node_list.index(u8_archive.u8_node_list[-1]))
|
||||
# Return the U8Archive object, the current node we're on, and the current name offset.
|
||||
return u8_archive, node_count, name_offset
|
||||
return u8_archive, node_count
|
||||
|
||||
|
||||
def pack_u8(input_path) -> bytes:
|
||||
@@ -274,34 +272,19 @@ def pack_u8(input_path) -> bytes:
|
||||
The data for the packed U8 archive.
|
||||
"""
|
||||
input_path = pathlib.Path(input_path)
|
||||
if os.path.isdir(input_path):
|
||||
if input_path.is_dir():
|
||||
# Append empty entries at the start for the root node, and then create the root U8Node() object, using rglob()
|
||||
# to read the total count of files and directories that will be packed so that we can add the total node count.
|
||||
u8_archive = U8Archive()
|
||||
u8_archive.file_name_list.append("")
|
||||
u8_archive.file_data_list.append(b'')
|
||||
u8_archive.u8_node_list.append(U8Node(256, 0, 0, sum(1 for _ in input_path.rglob('*')) + 1))
|
||||
u8_archive.u8_node_list.append(_U8Node(1, 0, 0, sum(1 for _ in input_path.rglob('*')) + 1))
|
||||
# Call the private function _pack_u8_dir() on the root note, which will recursively call itself to pack every
|
||||
# subdirectory and file. Discard node_count and name_offset since we don't care about them here, as they're
|
||||
# really only necessary for the directory recursion.
|
||||
u8_archive, _, _ = _pack_u8_dir(u8_archive, input_path, node_count=1, name_offset=1)
|
||||
u8_archive, _ = _pack_u8_dir(u8_archive, input_path, node_count=1, parent_node=0)
|
||||
return u8_archive.dump()
|
||||
elif os.path.isfile(input_path):
|
||||
# Simple code to handle if a single file is provided as input. Not really sure *why* you'd do this, since the
|
||||
# whole point of a U8 archive is to stitch files together, but it's here nonetheless.
|
||||
with open(input_path, "rb") as f:
|
||||
u8_archive = U8Archive()
|
||||
file_name = input_path.name
|
||||
file_data = f.read()
|
||||
# Append blank file name for the root node.
|
||||
u8_archive.file_name_list.append("")
|
||||
u8_archive.file_name_list.append(file_name)
|
||||
# Append blank data for the root node.
|
||||
u8_archive.file_data_list.append(b'')
|
||||
u8_archive.file_data_list.append(file_data)
|
||||
# Append generic U8Node for the root, followed by the actual file's node.
|
||||
u8_archive.u8_node_list.append(U8Node(256, 0, 0, 2))
|
||||
u8_archive.u8_node_list.append(U8Node(0, 1, 0, len(file_data)))
|
||||
return u8_archive.dump()
|
||||
elif input_path.is_file():
|
||||
raise ValueError("This does not appear to be a directory.")
|
||||
else:
|
||||
raise FileNotFoundError("Input file/directory: \"" + str(input_path) + "\" does not exist!")
|
||||
raise FileNotFoundError("Input directory: \"" + str(input_path) + "\" does not exist!")
|
||||
|
||||
6
src/libWiiPy/nand/__init__.py
Normal file
6
src/libWiiPy/nand/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
# "nand/__init__.py" from libWiiPy by NinjaCheetah & Contributors
|
||||
# https://github.com/NinjaCheetah/libWiiPy
|
||||
|
||||
from .emunand import *
|
||||
from .setting import *
|
||||
from .sys import *
|
||||
161
src/libWiiPy/nand/emunand.py
Normal file
161
src/libWiiPy/nand/emunand.py
Normal file
@@ -0,0 +1,161 @@
|
||||
# "nand/emunand.py" from libWiiPy by NinjaCheetah & Contributors
|
||||
# https://github.com/NinjaCheetah/libWiiPy
|
||||
#
|
||||
# Code for handling setting up and modifying a Wii EmuNAND.
|
||||
|
||||
import os
|
||||
import pathlib
|
||||
import shutil
|
||||
from ..title.title import Title
|
||||
from ..title.content import SharedContentMap as _SharedContentMap
|
||||
from .sys import UidSys as _UidSys
|
||||
|
||||
|
||||
class EmuNAND:
|
||||
"""
|
||||
An EmuNAND object that allows for creating and modifying Wii EmuNANDs. Requires the path to the root of the
|
||||
EmuNAND, and can optionally take in a callback function to send logs to.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
emunand_root : str, pathlib.Path
|
||||
The path to the EmuNAND root directory.
|
||||
callback : function
|
||||
A callback function to send EmuNAND logs to.
|
||||
|
||||
Attributes
|
||||
----------
|
||||
emunand_root : pathlib.Path
|
||||
The path to the EmuNAND root directory.
|
||||
"""
|
||||
def __init__(self, emunand_root: str | pathlib.Path, callback: callable = None):
|
||||
self.emunand_root = pathlib.Path(emunand_root)
|
||||
self.log = callback if callback is not None else None
|
||||
|
||||
self.import_dir = self.emunand_root.joinpath("import")
|
||||
self.meta_dir = self.emunand_root.joinpath("meta")
|
||||
self.shared1_dir = self.emunand_root.joinpath("shared1")
|
||||
self.shared2_dir = self.emunand_root.joinpath("shared2")
|
||||
self.sys_dir = self.emunand_root.joinpath("sys")
|
||||
self.ticket_dir = self.emunand_root.joinpath("ticket")
|
||||
self.title_dir = self.emunand_root.joinpath("title")
|
||||
self.tmp_dir = self.emunand_root.joinpath("tmp")
|
||||
self.wfs_dir = self.emunand_root.joinpath("wfs")
|
||||
|
||||
self.import_dir.mkdir(exist_ok=True)
|
||||
self.meta_dir.mkdir(exist_ok=True)
|
||||
self.shared1_dir.mkdir(exist_ok=True)
|
||||
self.shared2_dir.mkdir(exist_ok=True)
|
||||
self.sys_dir.mkdir(exist_ok=True)
|
||||
self.ticket_dir.mkdir(exist_ok=True)
|
||||
self.title_dir.mkdir(exist_ok=True)
|
||||
self.tmp_dir.mkdir(exist_ok=True)
|
||||
self.wfs_dir.mkdir(exist_ok=True)
|
||||
|
||||
def install_title(self, title: Title, skip_hash=False) -> None:
|
||||
"""
|
||||
Install the provided Title object to the EmuNAND. This mimics a real WAD installation done by ES.
|
||||
|
||||
This will create some system files required if they do not exist, but note that this alone is not enough for
|
||||
a working EmuNAND, other than for Dolphin which can fill in the gaps.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
title : libWiiPy.title.Title
|
||||
The loaded Title object to install.
|
||||
skip_hash : bool, optional
|
||||
Skip the hash check and install the title regardless of its hashes. Defaults to false.
|
||||
"""
|
||||
# Save the upper and lower portions of the Title ID, because these are used as target install directories.
|
||||
tid_upper = title.tmd.title_id[:8]
|
||||
tid_lower = title.tmd.title_id[8:]
|
||||
|
||||
# Tickets are installed as <tid_lower>.tik in /ticket/<tid_upper>/
|
||||
ticket_dir = self.ticket_dir.joinpath(tid_upper)
|
||||
ticket_dir.mkdir(exist_ok=True)
|
||||
open(ticket_dir.joinpath(tid_lower + ".tik"), "wb").write(title.wad.get_ticket_data())
|
||||
|
||||
# The TMD and normal contents are installed to /title/<tid_upper>/<tid_lower>/content/, with the tmd being named
|
||||
# title.tmd and the contents being named <cid>.app.
|
||||
title_dir = self.title_dir.joinpath(tid_upper)
|
||||
title_dir.mkdir(exist_ok=True)
|
||||
title_dir = title_dir.joinpath(tid_lower)
|
||||
title_dir.mkdir(exist_ok=True)
|
||||
content_dir = title_dir.joinpath("content")
|
||||
if content_dir.exists():
|
||||
shutil.rmtree(content_dir) # Clear the content directory so old contents aren't left behind.
|
||||
content_dir.mkdir(exist_ok=True)
|
||||
open(content_dir.joinpath("title.tmd"), "wb").write(title.wad.get_tmd_data())
|
||||
for content_file in range(0, title.tmd.num_contents):
|
||||
if title.tmd.content_records[content_file].content_type == 1:
|
||||
content_file_name = f"{title.tmd.content_records[content_file].content_id:08X}".lower()
|
||||
open(content_dir.joinpath(content_file_name + ".app"), "wb").write(
|
||||
title.get_content_by_index(content_file, skip_hash=skip_hash))
|
||||
title_dir.joinpath("data").mkdir(exist_ok=True) # Empty directory used for save data for the title.
|
||||
|
||||
# Shared contents need to be installed to /shared1/, with incremental names determined by /shared1/content.map.
|
||||
content_map_path = self.shared1_dir.joinpath("content.map")
|
||||
content_map = _SharedContentMap()
|
||||
existing_hashes = []
|
||||
if content_map_path.exists():
|
||||
content_map.load(open(content_map_path, "rb").read())
|
||||
for record in content_map.shared_records:
|
||||
existing_hashes.append(record.content_hash)
|
||||
for content_file in range(0, title.tmd.num_contents):
|
||||
if title.tmd.content_records[content_file].content_type == 32769:
|
||||
if title.tmd.content_records[content_file].content_hash not in existing_hashes:
|
||||
content_file_name = content_map.add_content(title.tmd.content_records[content_file].content_hash)
|
||||
open(self.shared1_dir.joinpath(content_file_name + ".app"), "wb").write(
|
||||
title.get_content_by_index(content_file, skip_hash=skip_hash))
|
||||
open(self.shared1_dir.joinpath("content.map"), "wb").write(content_map.dump())
|
||||
|
||||
# The "footer" or meta file is installed as title.met in /meta/<tid_upper>/<tid_lower>/. Only write this if meta
|
||||
# is not nothing.
|
||||
meta_data = title.wad.get_meta_data()
|
||||
if meta_data != b'':
|
||||
meta_dir = self.meta_dir.joinpath(tid_upper)
|
||||
meta_dir.mkdir(exist_ok=True)
|
||||
meta_dir = meta_dir.joinpath(tid_lower)
|
||||
meta_dir.mkdir(exist_ok=True)
|
||||
open(meta_dir.joinpath("title.met"), "wb").write(title.wad.get_meta_data())
|
||||
|
||||
# Ensure we have a uid.sys file created.
|
||||
uid_sys_path = self.sys_dir.joinpath("uid.sys")
|
||||
uid_sys = _UidSys()
|
||||
if not uid_sys_path.exists():
|
||||
uid_sys.create()
|
||||
|
||||
def uninstall_title(self, tid: str) -> None:
|
||||
"""
|
||||
Uninstall the Title with the specified Title ID from the EmuNAND. This will leave shared contents unmodified.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
tid : str
|
||||
The Title ID of the Title to uninstall.
|
||||
"""
|
||||
# Save the upper and lower portions of the Title ID, because these are used as target install directories.
|
||||
tid_upper = tid[:8]
|
||||
tid_lower = tid[8:]
|
||||
|
||||
if not self.title_dir.joinpath(tid_upper).joinpath(tid_lower).exists():
|
||||
raise ValueError(f"Title with Title ID {tid} does not appear to be installed!")
|
||||
|
||||
# Begin by removing the Ticket, which is installed to /ticket/<tid_upper>/<tid_lower>.tik
|
||||
if self.ticket_dir.joinpath(tid_upper).joinpath(tid_lower + ".tik").exists():
|
||||
os.remove(self.ticket_dir.joinpath(tid_upper).joinpath(tid_lower + ".tik"))
|
||||
|
||||
# The TMD and contents are stored in /title/<tid_upper>/<tid_lower>/. Remove the TMD and all contents, but don't
|
||||
# delete the entire directory if anything exists in data.
|
||||
title_dir = self.title_dir.joinpath(tid_upper).joinpath(tid_lower)
|
||||
if not title_dir.joinpath("data").exists():
|
||||
shutil.rmtree(title_dir)
|
||||
elif title_dir.joinpath("data").exists() and not os.listdir(title_dir.joinpath("data")):
|
||||
shutil.rmtree(title_dir)
|
||||
else:
|
||||
# There are files in data, so we only want to delete the content directory.
|
||||
shutil.rmtree(title_dir.joinpath("content"))
|
||||
|
||||
# On the off chance this title has a meta entry, delete that too.
|
||||
if self.meta_dir.joinpath(tid_upper).joinpath(tid_lower).joinpath("title.met").exists():
|
||||
shutil.rmtree(self.meta_dir.joinpath(tid_upper).joinpath(tid_lower))
|
||||
134
src/libWiiPy/nand/setting.py
Normal file
134
src/libWiiPy/nand/setting.py
Normal file
@@ -0,0 +1,134 @@
|
||||
# "nand/setting.py" from libWiiPy by NinjaCheetah & Contributors
|
||||
# https://github.com/NinjaCheetah/libWiiPy
|
||||
#
|
||||
# See https://wiibrew.org/wiki//title/00000001/00000002/data/setting.txt for information about setting.txt.
|
||||
|
||||
import io
|
||||
from ..shared import _pad_bytes
|
||||
|
||||
|
||||
_key = 0x73B5DBFA
|
||||
|
||||
class SettingTxt:
|
||||
"""
|
||||
A SettingTxt object that allows for decrypting and then parsing a setting.txt file from the Wii.
|
||||
|
||||
Attributes
|
||||
----------
|
||||
area : str
|
||||
The region of the System Menu this file matches with.
|
||||
model : str
|
||||
The model of the console, usually RVL-001 or RVL-101.
|
||||
dvd : int
|
||||
Unknown, might have to do with indicating support for scrapped DVD playback capabilities.
|
||||
mpch : str
|
||||
Unknown, generally accepted value is "0x7FFE".
|
||||
code : str
|
||||
Unknown code, may match with manufacturer code in serial number?
|
||||
serial_number : str
|
||||
Serial number of the console.
|
||||
video : str
|
||||
Video mode, either NTSC or PAL.
|
||||
game : str
|
||||
Another region code, possibly set by the hidden region select channel.
|
||||
"""
|
||||
def __init__(self):
|
||||
self.area: str = ""
|
||||
self.model: str = ""
|
||||
self.dvd: int = 0
|
||||
self.mpch: str = "" # What does this mean, Movie Player Channel? It's also a hex string, it seems.
|
||||
self.code: str = ""
|
||||
self.serial_number: str = ""
|
||||
self.video: str = ""
|
||||
self.game: str = ""
|
||||
|
||||
def load(self, setting_txt: bytes) -> None:
|
||||
"""
|
||||
Loads the raw data of an encrypted setting.txt file and decrypts it to parse its arguments
|
||||
|
||||
Parameters
|
||||
----------
|
||||
setting_txt : bytes
|
||||
The data of an encrypted setting.txt file.
|
||||
"""
|
||||
with io.BytesIO(setting_txt) as setting_data:
|
||||
global _key # I still don't actually know what *kind* of encryption this is.
|
||||
setting_txt_dec: [int] = []
|
||||
for i in range(0, 256):
|
||||
setting_txt_dec.append(int.from_bytes(setting_data.read(1)) ^ (_key & 0xff))
|
||||
_key = (_key << 1) | (_key >> 31)
|
||||
setting_txt_dec = bytes(setting_txt_dec)
|
||||
try:
|
||||
setting_str = setting_txt_dec.decode('utf-8')
|
||||
except UnicodeDecodeError:
|
||||
last_newline_pos = setting_txt_dec.rfind(b'\n') # This makes sure we don't try to decode any garbage data.
|
||||
setting_str = setting_txt_dec[:last_newline_pos + 1].decode('utf-8')
|
||||
self.load_decrypted(setting_str)
|
||||
|
||||
def load_decrypted(self, setting_txt: str) -> None:
|
||||
"""
|
||||
Loads the raw data of a decrypted setting.txt file and parses its arguments
|
||||
|
||||
Parameters
|
||||
----------
|
||||
setting_txt : str
|
||||
The data of a decrypted setting.txt file.
|
||||
"""
|
||||
setting_dict = {}
|
||||
# Iterate over every key in the file to create a dictionary.
|
||||
for line in setting_txt.splitlines():
|
||||
line = line.strip()
|
||||
if line is not None:
|
||||
key, value = line.split('=', 1)
|
||||
setting_dict[key.strip()] = value.strip()
|
||||
# Load the values from the dictionary into the object.
|
||||
self.area = setting_dict["AREA"]
|
||||
self.model = setting_dict["MODEL"]
|
||||
self.dvd = int(setting_dict["DVD"])
|
||||
self.mpch = setting_dict["MPCH"]
|
||||
self.code = setting_dict["CODE"]
|
||||
self.serial_number = setting_dict["SERNO"]
|
||||
self.video = setting_dict["VIDEO"]
|
||||
self.game = setting_dict["GAME"]
|
||||
|
||||
def dump(self) -> bytes:
|
||||
"""
|
||||
Dumps the SettingTxt object back into an encrypted bytes that the Wii can load.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bytes
|
||||
The setting.txt file as encrypted bytes.
|
||||
"""
|
||||
setting_str = self.dump_decrypted()
|
||||
setting_txt_dec = setting_str.encode()
|
||||
global _key
|
||||
# This could probably be made more efficient somehow.
|
||||
setting_txt_enc: [int] = []
|
||||
with io.BytesIO(setting_txt_dec) as setting_data:
|
||||
for i in range(0, len(setting_txt_dec)):
|
||||
setting_txt_enc.append(int.from_bytes(setting_data.read(1)) ^ (_key & 0xff))
|
||||
_key = (_key << 1) | (_key >> 31)
|
||||
setting_txt_enc = _pad_bytes(bytes(setting_txt_enc), 256)
|
||||
return setting_txt_enc
|
||||
|
||||
def dump_decrypted(self) -> str:
|
||||
"""
|
||||
Dumps the SettingTxt object into a decrypted string.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
The setting.txt file as decrypted text.
|
||||
"""
|
||||
# Write the keys back into a text file that can then be manually edited or re-encrypted.
|
||||
setting_txt = ""
|
||||
setting_txt += f"AREA={self.area}\r\n"
|
||||
setting_txt += f"MODEL={self.model}\r\n"
|
||||
setting_txt += f"DVD={self.dvd}\r\n"
|
||||
setting_txt += f"MPCH={self.mpch}\r\n"
|
||||
setting_txt += f"CODE={self.code}\r\n"
|
||||
setting_txt += f"SERNO={self.serial_number}\r\n"
|
||||
setting_txt += f"VIDEO={self.video}\r\n"
|
||||
setting_txt += f"GAME={self.game}\r\n"
|
||||
return setting_txt
|
||||
124
src/libWiiPy/nand/sys.py
Normal file
124
src/libWiiPy/nand/sys.py
Normal file
@@ -0,0 +1,124 @@
|
||||
# "nand/sys.py" from libWiiPy by NinjaCheetah & Contributors
|
||||
# https://github.com/NinjaCheetah/libWiiPy
|
||||
#
|
||||
# See https://wiibrew.org/wiki//sys/uid.sys for information about uid.sys.
|
||||
|
||||
import io
|
||||
import binascii
|
||||
from typing import List
|
||||
from dataclasses import dataclass as _dataclass
|
||||
|
||||
|
||||
@_dataclass
|
||||
class _UidSysEntry:
|
||||
"""
|
||||
A _UidSysEntry object used to store an entry in uid.sys. Private class used by the sys module.
|
||||
|
||||
Attributes
|
||||
----------
|
||||
title_id : str
|
||||
The Title ID of the title this entry corresponds with.
|
||||
uid : int
|
||||
The UID assigned to the title this entry corresponds with.
|
||||
"""
|
||||
title_id: str
|
||||
uid: int
|
||||
|
||||
|
||||
class UidSys:
|
||||
"""
|
||||
A UidSys object to parse and edit the uid.sys file stored in /sys/ on the Wii's NAND. This file is used to track all
|
||||
the titles that have been launched on a console.
|
||||
|
||||
Attributes
|
||||
----------
|
||||
uid_entries : List[_UidSysEntry]
|
||||
The entries stored in the uid.sys file.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.uid_entries: List[_UidSysEntry] = []
|
||||
|
||||
def load(self, uid_sys: bytes) -> None:
|
||||
"""
|
||||
Loads the raw data of uid.sys and parses it into a list of entries.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
uid_sys : bytes
|
||||
The data of a uid.sys file.
|
||||
"""
|
||||
# Sanity check to ensure the length is divisible by 12 bytes. If it isn't, then it is malformed.
|
||||
if (len(uid_sys) % 12) != 0:
|
||||
raise ValueError("The provided uid.sys appears to be corrupted!")
|
||||
entry_count = len(uid_sys) // 12
|
||||
with io.BytesIO(uid_sys) as uid_data:
|
||||
for i in range(entry_count):
|
||||
title_id = binascii.hexlify(uid_data.read(8)).decode()
|
||||
uid_data.seek(uid_data.tell() + 2)
|
||||
uid = int.from_bytes(uid_data.read(2))
|
||||
self.uid_entries.append(_UidSysEntry(title_id, uid))
|
||||
|
||||
def dump(self) -> bytes:
|
||||
"""
|
||||
Dumps the UidSys object back into a uid.sys file.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bytes
|
||||
The raw data of the uid.sys file.
|
||||
"""
|
||||
uid_data = b''
|
||||
for record in self.uid_entries:
|
||||
uid_data += binascii.unhexlify(record.title_id.encode())
|
||||
uid_data += b'\x00' * 2
|
||||
uid_data += int.to_bytes(record.uid, 2)
|
||||
return uid_data
|
||||
|
||||
def add(self, title_id: str | bytes) -> int:
|
||||
"""
|
||||
Adds a new Title ID to the uid.sys file and returns the UID assigned to that title.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
title_id : str, bytes
|
||||
The Title ID to add.
|
||||
|
||||
Returns
|
||||
-------
|
||||
int
|
||||
The UID assigned to the new Title ID.
|
||||
"""
|
||||
if type(title_id) is bytes:
|
||||
# This catches the format b'0000000100000002'
|
||||
if len(title_id) == 16:
|
||||
title_id_converted = title_id.encode()
|
||||
# This catches the format b'\x00\x00\x00\x01\x00\x00\x00\x02'
|
||||
elif len(title_id) == 8:
|
||||
title_id_converted = binascii.hexlify(title_id).decode()
|
||||
# If it isn't one of those lengths, it cannot possibly be valid, so reject it.
|
||||
else:
|
||||
raise ValueError("Title ID is not valid!")
|
||||
# Allow for a string like "0000000100000002"
|
||||
elif type(title_id) is str:
|
||||
if len(title_id) != 16:
|
||||
raise ValueError("Title ID is not valid!")
|
||||
title_id_converted = title_id
|
||||
else:
|
||||
raise TypeError("Title ID type is not valid! It must be either type str or bytes.")
|
||||
# Generate the new UID by incrementing the current highest UID by 1.
|
||||
try:
|
||||
new_uid = self.uid_entries[-1].uid + 1
|
||||
except IndexError:
|
||||
new_uid = 4096
|
||||
self.uid_entries.append(_UidSysEntry(title_id_converted, new_uid))
|
||||
return new_uid
|
||||
|
||||
def create(self) -> None:
|
||||
"""
|
||||
Creates a new uid.sys file and initializes it with the standard first entry of 1-2 with UID 4096. This allows
|
||||
for setting up a uid.sys file without having to load an existing one.
|
||||
"""
|
||||
if len(self.uid_entries) != 0:
|
||||
raise Exception("A uid.sys file appears to already exist!")
|
||||
self.add("0000000100000002")
|
||||
@@ -4,12 +4,10 @@
|
||||
# This file defines general functions that may be useful in other modules of libWiiPy. Putting them here cuts down on
|
||||
# clutter in other files.
|
||||
|
||||
import binascii
|
||||
|
||||
|
||||
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
|
||||
----------
|
||||
@@ -29,9 +27,10 @@ def align_value(value, alignment=64) -> int:
|
||||
return value
|
||||
|
||||
|
||||
def pad_bytes(data, alignment=64) -> bytes:
|
||||
def _pad_bytes(data, alignment=64) -> bytes:
|
||||
"""
|
||||
Pads the provided bytes object 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
|
||||
----------
|
||||
@@ -50,22 +49,67 @@ def pad_bytes(data, alignment=64) -> bytes:
|
||||
return data
|
||||
|
||||
|
||||
def convert_tid_to_iv(title_id: str) -> bytes:
|
||||
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 _bitmask(x: int) -> int:
|
||||
return 1 << x
|
||||
|
||||
|
||||
_wii_menu_versions = {
|
||||
"Prelaunch": [0, 1, 2],
|
||||
"1.0J": 64,
|
||||
"1.0U": 33,
|
||||
"1.0E": 34,
|
||||
"2.0J": 128,
|
||||
"2.0U": 97,
|
||||
"2.0E": 130,
|
||||
"2.1E": 162,
|
||||
"2.2J": 192,
|
||||
"2.2U": 193,
|
||||
"2.2E": 194,
|
||||
"3.0J": 224,
|
||||
"3.0U": 225,
|
||||
"3.0E": 226,
|
||||
"3.1J": 256,
|
||||
"3.1U": 257,
|
||||
"3.1E": 258,
|
||||
"3.2J": 288,
|
||||
"3.2U": 289,
|
||||
"3.2E": 290,
|
||||
"3.3J": 352,
|
||||
"3.3U": 353,
|
||||
"3.3E": 354,
|
||||
"3.3K": 326,
|
||||
"3.4J": 384,
|
||||
"3.4U": 385,
|
||||
"3.4E": 386,
|
||||
"3.5K": 390,
|
||||
"4.0J": 416,
|
||||
"4.0U": 417,
|
||||
"4.0E": 418,
|
||||
"4.1J": 448,
|
||||
"4.1U": 449,
|
||||
"4.1E": 450,
|
||||
"4.1K": 454,
|
||||
"4.2J": 480,
|
||||
"4.2U": 481,
|
||||
"4.2E": 482,
|
||||
"4.2K": 486,
|
||||
"4.3J": 512,
|
||||
"4.3U": 513,
|
||||
"4.3E": 514,
|
||||
"4.3K": 518,
|
||||
"4.3U-Mini": 4609,
|
||||
"4.3E-Mini": 4610
|
||||
}
|
||||
|
||||
|
||||
_vwii_menu_versions = {
|
||||
"vWii-1.0.0J": 512,
|
||||
"vWii-1.0.0U": 513,
|
||||
"vWii-1.0.0E": 514,
|
||||
"vWii-4.0.0J": 544,
|
||||
"vWii-4.0.0U": 545,
|
||||
"vWii-4.0.0E": 546,
|
||||
"vWii-5.2.0J": 608,
|
||||
"vWii-5.2.0U": 609,
|
||||
"vWii-5.2.0E": 610,
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
|
||||
from .content import *
|
||||
from .crypto import *
|
||||
from .iospatcher import *
|
||||
from .nus import *
|
||||
from .ticket import *
|
||||
from .title import *
|
||||
from .tmd import *
|
||||
from .util import *
|
||||
from .wad import *
|
||||
|
||||
@@ -7,10 +7,14 @@ common_key = 'ebe42a225e8593e448d9c5457381aaf7'
|
||||
korean_key = '63b82bb4f4614e2e13f2fefbba4c9b7e'
|
||||
vwii_key = '30bfc76e7c19afbb23163330ced7c28d'
|
||||
|
||||
development_key = 'a1604a6a7123b529ae8bec32c816fcaa'
|
||||
|
||||
def get_common_key(common_key_index) -> bytes:
|
||||
|
||||
def get_common_key(common_key_index, dev=False) -> bytes:
|
||||
"""
|
||||
Gets the specified Wii Common Key based on the index provided.
|
||||
Gets the specified Wii Common Key based on the index provided. If an invalid common key index is provided, this
|
||||
function falls back on always returning key 0 (the Common Key). If the kwarg "dev" is specified, then key 0 will
|
||||
point to the development common key rather than the retail one. Keys 1 and 2 are unaffected by this argument.
|
||||
|
||||
Possible values for common_key_index: 0: Common Key, 1: Korean Key, 2: vWii Key
|
||||
|
||||
@@ -18,6 +22,8 @@ def get_common_key(common_key_index) -> bytes:
|
||||
----------
|
||||
common_key_index : int
|
||||
The index of the common key to be returned.
|
||||
dev : bool
|
||||
If the dev keys should be used in place of the retail keys. Only affects key 0.
|
||||
|
||||
Returns
|
||||
-------
|
||||
@@ -26,11 +32,14 @@ def get_common_key(common_key_index) -> bytes:
|
||||
"""
|
||||
match common_key_index:
|
||||
case 0:
|
||||
common_key_bin = binascii.unhexlify(common_key)
|
||||
if dev:
|
||||
common_key_bin = binascii.unhexlify(development_key)
|
||||
else:
|
||||
common_key_bin = binascii.unhexlify(common_key)
|
||||
case 1:
|
||||
common_key_bin = binascii.unhexlify(korean_key)
|
||||
case 2:
|
||||
common_key_bin = binascii.unhexlify(vwii_key)
|
||||
case _:
|
||||
raise ValueError("The common key index provided, " + str(common_key_index) + ", does not exist.")
|
||||
common_key_bin = binascii.unhexlify(common_key)
|
||||
return common_key_bin
|
||||
|
||||
@@ -3,14 +3,24 @@
|
||||
#
|
||||
# See https://wiibrew.org/wiki/Title for details about how titles are formatted
|
||||
|
||||
import binascii
|
||||
import io
|
||||
import sys
|
||||
import hashlib
|
||||
from typing import List
|
||||
from ..types import ContentRecord
|
||||
from dataclasses import dataclass as _dataclass
|
||||
from enum import IntEnum as _IntEnum
|
||||
from ..types import _ContentRecord
|
||||
from ..shared import _pad_bytes, _align_value
|
||||
from .crypto import decrypt_content, encrypt_content
|
||||
|
||||
|
||||
class ContentType(_IntEnum):
|
||||
NORMAL = 1
|
||||
HASH_TREE = 3
|
||||
DLC = 16385
|
||||
SHARED = 32769
|
||||
|
||||
|
||||
class ContentRegion:
|
||||
"""
|
||||
A ContentRegion object to parse the continuous content region of a WAD. Allows for retrieving content from the
|
||||
@@ -18,20 +28,20 @@ class ContentRegion:
|
||||
|
||||
Attributes
|
||||
----------
|
||||
content_records : List[ContentRecord]
|
||||
content_records : List[_ContentRecord]
|
||||
The content records for the content stored in the region.
|
||||
num_contents : int
|
||||
The total number of contents stored in the region.
|
||||
"""
|
||||
|
||||
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.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_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.
|
||||
|
||||
@@ -39,13 +49,13 @@ class ContentRegion:
|
||||
----------
|
||||
content_region : bytes
|
||||
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.
|
||||
"""
|
||||
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:
|
||||
# Get the total size of the content region.
|
||||
self.content_region_size = sys.getsizeof(content_region_data)
|
||||
self.num_contents = len(self.content_records)
|
||||
# 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.
|
||||
@@ -56,7 +66,7 @@ class ContentRegion:
|
||||
start_offset += 64 - (content.content_size % 64)
|
||||
self.content_start_offsets.append(start_offset)
|
||||
# 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.
|
||||
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.
|
||||
@@ -68,7 +78,7 @@ class ContentRegion:
|
||||
content_enc = content_region_data.read(bytes_to_read)
|
||||
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
|
||||
bytes object and sets the raw content region variable to this result, then calls load() again to make sure the
|
||||
@@ -77,19 +87,31 @@ class ContentRegion:
|
||||
Returns
|
||||
-------
|
||||
bytes
|
||||
The full WAD file as bytes.
|
||||
The full ContentRegion as bytes, including padding between content.
|
||||
int
|
||||
The size of the ContentRegion, including padding.
|
||||
"""
|
||||
content_region_data = b''
|
||||
for content in self.content_list:
|
||||
# If this isn't the first content, pad the whole region to 64 bytes before the next one.
|
||||
if content_region_data != b'':
|
||||
content_region_data = _pad_bytes(content_region_data, 64)
|
||||
# Calculate padding after this content before the next one.
|
||||
padding_bytes = 0
|
||||
if (len(content) % 64) != 0:
|
||||
padding_bytes = 64 - (len(content) % 64)
|
||||
if (len(content) % 16) != 0:
|
||||
padding_bytes = 16 - (len(content) % 16)
|
||||
# Write content data, then the padding afterward if necessary.
|
||||
content_region_data += content
|
||||
if padding_bytes > 0:
|
||||
content_region_data += b'\x00' * padding_bytes
|
||||
return content_region_data
|
||||
# Calculate the size of the whole content region.
|
||||
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:
|
||||
"""
|
||||
@@ -105,6 +127,9 @@ class ContentRegion:
|
||||
bytes
|
||||
The encrypted content listed in the content record.
|
||||
"""
|
||||
if index >= self.num_contents:
|
||||
raise ValueError(f"You are trying to get the content at index {index}, but no content with that "
|
||||
f"index exists!")
|
||||
content_enc = self.content_list[index]
|
||||
return content_enc
|
||||
|
||||
@@ -115,23 +140,18 @@ class ContentRegion:
|
||||
Parameters
|
||||
----------
|
||||
cid : int
|
||||
The Content ID of the content you want to get. Expected to be in decimal form.
|
||||
The Content ID of the content you want to get. Expected to be in decimal form, not hex.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bytes
|
||||
The encrypted content listed in the content record.
|
||||
"""
|
||||
# Find the index of the requested Content ID.
|
||||
content_index = None
|
||||
for content in self.content_records:
|
||||
if content.content_id == cid:
|
||||
content_index = content.index
|
||||
# If finding a matching ID was unsuccessful, that means that no content with that ID is in the TMD, so
|
||||
# return a Value Error.
|
||||
if content_index is None:
|
||||
raise ValueError("The Content ID requested does not exist in the TMD's content records.")
|
||||
# Call get_enc_content_by_index() using the index we just found.
|
||||
try:
|
||||
content_index = self.get_index_from_cid(cid)
|
||||
except ValueError:
|
||||
raise ValueError(f"You are trying to get a content with Content ID {cid}, "
|
||||
f"but no content with that ID exists!")
|
||||
content_enc = self.get_enc_content_by_index(content_index)
|
||||
return content_enc
|
||||
|
||||
@@ -146,7 +166,7 @@ class ContentRegion:
|
||||
"""
|
||||
return self.content_list
|
||||
|
||||
def get_content_by_index(self, index: int, title_key: bytes) -> bytes:
|
||||
def get_content_by_index(self, index: int, title_key: bytes, skip_hash=False) -> bytes:
|
||||
"""
|
||||
Gets an individual content from the content region based on the provided index, in decrypted form.
|
||||
|
||||
@@ -156,58 +176,60 @@ class ContentRegion:
|
||||
The index of the content you want to get.
|
||||
title_key : bytes
|
||||
The Title Key for the title the content is from.
|
||||
skip_hash : bool, optional
|
||||
Skip the hash check and return the content regardless of its hash. Defaults to false.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bytes
|
||||
The decrypted content listed in the content record.
|
||||
"""
|
||||
# Load the encrypted content at the specified index and then decrypt it with the Title Key.
|
||||
# Get the content index in the Content Record to ensure decryption works properly.
|
||||
cnt_index = self.content_records[index].index
|
||||
content_enc = self.get_enc_content_by_index(index)
|
||||
content_dec = decrypt_content(content_enc, title_key, self.content_records[index].index,
|
||||
self.content_records[index].content_size)
|
||||
content_dec = decrypt_content(content_enc, title_key, cnt_index, self.content_records[index].content_size)
|
||||
# Hash the decrypted content and ensure that the hash matches the one in its Content Record.
|
||||
# If it does not, then something has gone wrong in the decryption, and an error will be thrown.
|
||||
content_dec_hash = hashlib.sha1(content_dec).hexdigest()
|
||||
content_record_hash = str(self.content_records[index].content_hash.decode())
|
||||
# Compare the hash and throw a ValueError if the hash doesn't match.
|
||||
if content_dec_hash != content_record_hash:
|
||||
raise ValueError("Content hash did not match the expected hash in its record! The incorrect Title Key may "
|
||||
"have been used!.\n"
|
||||
"Expected hash is: {}\n".format(content_record_hash) +
|
||||
"Actual hash is: {}".format(content_dec_hash))
|
||||
if skip_hash:
|
||||
print("Ignoring hash mismatch for content index " + str(index))
|
||||
else:
|
||||
raise ValueError("Content hash did not match the expected hash in its record! The incorrect Title Key "
|
||||
"may have been used!\n"
|
||||
"Expected hash is: {}\n".format(content_record_hash) +
|
||||
"Actual hash is: {}".format(content_dec_hash))
|
||||
return content_dec
|
||||
|
||||
def get_content_by_cid(self, cid: int, title_key: bytes) -> bytes:
|
||||
def get_content_by_cid(self, cid: int, title_key: bytes, skip_hash=False) -> bytes:
|
||||
"""
|
||||
Gets an individual content from the content region based on the provided Content ID, in decrypted form.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
cid : int
|
||||
The Content ID of the content you want to get. Expected to be in decimal form.
|
||||
The Content ID of the content you want to get. Expected to be in decimal form, not hex.
|
||||
title_key : bytes
|
||||
The Title Key for the title the content is from.
|
||||
skip_hash : bool, optional
|
||||
Skip the hash check and return the content regardless of its hash. Defaults to false.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bytes
|
||||
The decrypted content listed in the content record.
|
||||
"""
|
||||
# Find the index of the requested Content ID.
|
||||
content_index = None
|
||||
for content in self.content_records:
|
||||
if content.content_id == cid:
|
||||
content_index = content.index
|
||||
# If finding a matching ID was unsuccessful, that means that no content with that ID is in the TMD, so
|
||||
# return a Value Error.
|
||||
if content_index is None:
|
||||
raise ValueError("The Content ID requested does not exist in the TMD's content records.")
|
||||
# Call get_content_by_index() using the index we just found.
|
||||
content_dec = self.get_content_by_index(content_index, title_key)
|
||||
try:
|
||||
content_index = self.get_index_from_cid(cid)
|
||||
except ValueError:
|
||||
raise ValueError(f"You are trying to get a content with Content ID {cid}, "
|
||||
f"but no content with that ID exists!")
|
||||
content_dec = self.get_content_by_index(content_index, title_key, skip_hash)
|
||||
return content_dec
|
||||
|
||||
def get_contents(self, title_key: bytes) -> List[bytes]:
|
||||
def get_contents(self, title_key: bytes, skip_hash=False) -> List[bytes]:
|
||||
"""
|
||||
Gets a list of all contents from the content region, in decrypted form.
|
||||
|
||||
@@ -215,6 +237,8 @@ class ContentRegion:
|
||||
----------
|
||||
title_key : bytes
|
||||
The Title Key for the title the content is from.
|
||||
skip_hash : bool, optional
|
||||
Skip the hash check and return the content regardless of its hash. Defaults to false.
|
||||
|
||||
Returns
|
||||
-------
|
||||
@@ -224,23 +248,46 @@ class ContentRegion:
|
||||
dec_contents: List[bytes] = []
|
||||
# Iterate over every content, get the decrypted version of it, then add it to a list and return it.
|
||||
for content in range(self.num_contents):
|
||||
dec_contents.append(self.get_content_by_index(content, title_key))
|
||||
dec_contents.append(self.get_content_by_index(content, title_key, skip_hash))
|
||||
return dec_contents
|
||||
|
||||
def set_enc_content(self, enc_content: bytes, cid: int, index: int, content_type: int, content_size: int,
|
||||
def get_index_from_cid(self, cid: int) -> int:
|
||||
"""
|
||||
Gets the index of a content by its Content ID.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
cid : int
|
||||
The Content ID to get the index of.
|
||||
|
||||
Returns
|
||||
-------
|
||||
int
|
||||
The content index.
|
||||
"""
|
||||
# Get a list of the current Content IDs, so we can make sure the target one exists.
|
||||
content_ids = []
|
||||
for record in self.content_records:
|
||||
content_ids.append(record.content_id)
|
||||
if cid not in content_ids:
|
||||
raise ValueError("The specified Content ID does not exist!")
|
||||
index = content_ids.index(cid)
|
||||
return index
|
||||
|
||||
def add_enc_content(self, enc_content: bytes, cid: int, index: int, content_type: int, content_size: int,
|
||||
content_hash: bytes) -> None:
|
||||
"""
|
||||
Sets the provided index to a new content with the provided Content ID. Hashes and size of the content are
|
||||
set in the content record, with a new record being added if necessary.
|
||||
Adds a new encrypted content to the ContentRegion, and adds the provided Content ID, index, content type,
|
||||
content size, and content hash to a new record in the ContentRecord list.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
enc_content : bytes
|
||||
The new encrypted content to set.
|
||||
The new encrypted content to add.
|
||||
cid : int
|
||||
The Content ID to assign the new content in the content record.
|
||||
index : int
|
||||
The index to place the new content at.
|
||||
The index used when encrypting the new content.
|
||||
content_type : int
|
||||
The type of the new content.
|
||||
content_size : int
|
||||
@@ -248,54 +295,115 @@ class ContentRegion:
|
||||
content_hash : bytes
|
||||
The hash of the new encrypted content when decrypted.
|
||||
"""
|
||||
# Save the number of contents currently in the content region and records.
|
||||
num_contents = len(self.content_records)
|
||||
# Check if a record already exists for this index. If it doesn't, create it.
|
||||
if (index + 1) > num_contents:
|
||||
# Ensure that you aren't attempting to create a gap before appending.
|
||||
if (index + 1) > num_contents + 1:
|
||||
raise ValueError("You are trying to set the content at position " + str(index) + ", but no content "
|
||||
"exists at position " + str(index - 1) + "!")
|
||||
self.content_records.append(ContentRecord(cid, index, content_type, content_size, content_hash))
|
||||
# If it does, reassign the values in it.
|
||||
else:
|
||||
self.content_records[index].content_id = cid
|
||||
self.content_records[index].content_type = content_type
|
||||
self.content_records[index].content_size = content_size
|
||||
self.content_records[index].content_hash = content_hash
|
||||
# Check if a content already occupies the provided index. If it does, reassign it to the new content, if it
|
||||
# doesn't, then append a new entry.
|
||||
if (index + 1) > num_contents:
|
||||
self.content_list.append(enc_content)
|
||||
else:
|
||||
self.content_list[index] = enc_content
|
||||
# Check to make sure this isn't reusing an already existing Content ID or index first.
|
||||
for record in self.content_records:
|
||||
if record.content_id == cid:
|
||||
raise ValueError("Content with a Content ID of " + str(cid) + " already exists!")
|
||||
elif record.index == index:
|
||||
raise ValueError("Content with an index of " + str(index) + " already exists!")
|
||||
# If we're good, then append all the data and create a new ContentRecord().
|
||||
self.content_list.append(enc_content)
|
||||
self.content_records.append(_ContentRecord(cid, index, content_type, content_size, content_hash))
|
||||
self.num_contents += 1
|
||||
|
||||
def set_content(self, dec_content: bytes, cid: int, index: int, content_type: int, title_key: bytes) -> None:
|
||||
def add_content(self, dec_content: bytes, cid: int, content_type: int, title_key: bytes) -> None:
|
||||
"""
|
||||
Sets the provided index to a new content with the provided Content ID. Hashes and size of the content are
|
||||
set in the content record, with a new record being added if necessary.
|
||||
Adds a new decrypted content to the end of the ContentRegion, and adds the provided Content ID, content type,
|
||||
content size, and content hash to a new record in the ContentRecord list. The index will be automatically
|
||||
assigned by incrementing the current highest index in the records.
|
||||
|
||||
This first gets the content hash and size from the provided data, and then encrypts the content with the
|
||||
provided Title Key before adding it to the ContentRegion.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
dec_content : bytes
|
||||
The new decrypted content to add.
|
||||
cid : int
|
||||
The Content ID to assign the new content in the content record.
|
||||
content_type : int
|
||||
The type of the new content.
|
||||
title_key : bytes
|
||||
The Title Key that matches the other content in the ContentRegion.
|
||||
"""
|
||||
# Find the current highest content index and increment it for this content.
|
||||
content_indices = []
|
||||
for record in self.content_records:
|
||||
content_indices.append(record.index)
|
||||
index = max(content_indices) + 1
|
||||
content_size = len(dec_content)
|
||||
content_hash = str.encode(hashlib.sha1(dec_content).hexdigest())
|
||||
enc_content = encrypt_content(dec_content, title_key, index)
|
||||
self.add_enc_content(enc_content, cid, index, content_type, content_size, content_hash)
|
||||
|
||||
def set_enc_content(self, enc_content: bytes, index: int, content_size: int, content_hash: bytes, cid: int = None,
|
||||
content_type: int = None) -> None:
|
||||
"""
|
||||
Sets the content at the provided content index to the provided new encrypted content. The provided hash and
|
||||
content size are set in the corresponding content record. A new Content ID or content type can also be
|
||||
specified, but if it isn't then the current values are preserved.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
enc_content : bytes
|
||||
The new encrypted content to set.
|
||||
index : int
|
||||
The target index to set the new content at.
|
||||
content_size : int
|
||||
The size of the new encrypted content when decrypted.
|
||||
content_hash : bytes
|
||||
The hash of the new encrypted content when decrypted.
|
||||
cid : int, optional
|
||||
The Content ID to assign the new content in the content record. Current value will be preserved if not set.
|
||||
content_type : int, optional
|
||||
The type of the new content. Current value will be preserved if not set.
|
||||
"""
|
||||
if index >= self.num_contents:
|
||||
raise ValueError(f"You are trying to set the content at index {index}, but no content with that "
|
||||
f"index currently exists!")
|
||||
# Reassign the values, but only set the optional ones if they were passed.
|
||||
self.content_records[index].content_size = content_size
|
||||
self.content_records[index].content_hash = content_hash
|
||||
if cid is not None:
|
||||
self.content_records[index].content_id = cid
|
||||
if content_type is not None:
|
||||
self.content_records[index].content_type = content_type
|
||||
# Add blank entries to the list to ensure that its length matches the length of the content record list.
|
||||
while len(self.content_list) < len(self.content_records):
|
||||
self.content_list.append(b'')
|
||||
self.content_list[index] = enc_content
|
||||
|
||||
def set_content(self, dec_content: bytes, index: int, title_key: bytes, cid: int = None,
|
||||
content_type: int = None) -> None:
|
||||
"""
|
||||
Sets the content at the provided content index to the provided new decrypted content. The hash and content size
|
||||
of this content will be generated and then set in the corresponding content record. A new Content ID or content
|
||||
type can also be specified, but if it isn't then the current values are preserved.
|
||||
|
||||
The provided Title Key is used to encrypt the content so that it can be set in the ContentRegion.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
dec_content : bytes
|
||||
The new decrypted content to set.
|
||||
cid : int
|
||||
The Content ID to assign the new content in the content record.
|
||||
index : int
|
||||
The index to place the new content at.
|
||||
content_type : int
|
||||
The type of the new content.
|
||||
title_key : bytes
|
||||
The Title Key that matches the new decrypted content.
|
||||
cid : int
|
||||
The Content ID to assign the new content in the content record.
|
||||
content_type : int
|
||||
The type of the new content.
|
||||
"""
|
||||
# Store the size of the new content.
|
||||
dec_content_size = len(dec_content)
|
||||
content_size = len(dec_content)
|
||||
# Calculate the hash of the new content.
|
||||
dec_content_hash = str.encode(hashlib.sha1(dec_content).hexdigest())
|
||||
# Encrypt the content using the provided Title Key and index.
|
||||
enc_content = encrypt_content(dec_content, title_key, index)
|
||||
content_hash = str.encode(hashlib.sha1(dec_content).hexdigest())
|
||||
# Encrypt the content using the provided Title Key and the index from the Content Record, to ensure that
|
||||
# encryption will succeed even if the provided index doesn't match the content's index.
|
||||
enc_content = encrypt_content(dec_content, title_key, self.content_records[index].index)
|
||||
# Pass values to set_enc_content()
|
||||
self.set_enc_content(enc_content, cid, index, content_type, dec_content_size, dec_content_hash)
|
||||
self.set_enc_content(enc_content, index, content_size, content_hash, cid, content_type)
|
||||
|
||||
def load_enc_content(self, enc_content: bytes, index: int) -> None:
|
||||
"""
|
||||
@@ -310,41 +418,188 @@ class ContentRegion:
|
||||
index : int
|
||||
The content index to load the content at.
|
||||
"""
|
||||
if (index + 1) > len(self.content_records) or len(self.content_records) == 0:
|
||||
raise IndexError("No content records have been loaded, or that index is higher than the highest entry in "
|
||||
"the content records.")
|
||||
if (index + 1) > len(self.content_list):
|
||||
self.content_list.append(enc_content)
|
||||
else:
|
||||
self.content_list[index] = enc_content
|
||||
if index >= self.num_contents:
|
||||
raise ValueError(f"You are trying to load the content at index {index}, but no content with that "
|
||||
f"index currently exists! Make sure the correct content records have been loaded.")
|
||||
# Add blank entries to the list to ensure that its length matches the length of the content record list.
|
||||
while len(self.content_list) < len(self.content_records):
|
||||
self.content_list.append(b'')
|
||||
self.content_list[index] = enc_content
|
||||
|
||||
def load_content(self, dec_content: bytes, index: int, title_key: bytes) -> None:
|
||||
"""
|
||||
Loads the provided decrypted content into the content region at the specified index, but first checks to make
|
||||
sure it matches the record at that index before loading. This content will be encrypted when loaded.
|
||||
Loads the provided decrypted content into the ContentRegion at the specified index, but first checks to make
|
||||
sure that it matches the corresponding record. This content will then be encrypted using the provided Title Key
|
||||
before being loaded.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
dec_content : bytes
|
||||
The decrypted content to load.
|
||||
index : int
|
||||
The content index to load the content at.
|
||||
The index to load the content at.
|
||||
title_key: bytes
|
||||
The Title Key that matches the decrypted content.
|
||||
"""
|
||||
# Make sure that content records exist and that the provided index exists in them.
|
||||
if (index + 1) > len(self.content_records) or len(self.content_records) == 0:
|
||||
raise IndexError("No content records have been loaded, or that index is higher than the highest entry in "
|
||||
"the content records.")
|
||||
if index >= self.num_contents:
|
||||
raise ValueError(f"You are trying to load the content at index {index}, but no content with that "
|
||||
f"index currently exists! Make sure the correct content records have been loaded.")
|
||||
# Check the hash of the content against the hash stored in the record to ensure it matches.
|
||||
content_hash = hashlib.sha1(dec_content).hexdigest()
|
||||
if content_hash != self.content_records[index].content_hash.decode():
|
||||
raise ValueError("The decrypted content provided does not match the record at the provided index. \n"
|
||||
"Expected hash is: {}\n".format(self.content_records[index].content_hash.decode()) +
|
||||
"Actual hash is: {}".format(content_hash))
|
||||
# Add blank entries to the list to ensure that its length matches the length of the content record list.
|
||||
while len(self.content_list) < len(self.content_records):
|
||||
self.content_list.append(b'')
|
||||
# If the hash matches, encrypt the content and set it where it belongs.
|
||||
enc_content = encrypt_content(dec_content, title_key, index)
|
||||
if (index + 1) > len(self.content_list):
|
||||
self.content_list.append(enc_content)
|
||||
# This uses the index from the content records instead of just the index given, because there are some poorly
|
||||
# made custom WADs out there that don't have the contents in order, for whatever reason.
|
||||
enc_content = encrypt_content(dec_content, title_key, self.content_records[index].index)
|
||||
self.content_list[index] = enc_content
|
||||
|
||||
def remove_content_by_index(self, index: int) -> None:
|
||||
"""
|
||||
Removes the content at the specified index from the ContentRegion and content records.
|
||||
|
||||
This will allow gaps to be left in content indices, however this should not cause any issues.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
index : int
|
||||
The index of the content you want to remove.
|
||||
"""
|
||||
if index >= self.num_contents:
|
||||
raise ValueError(f"You are trying to remove the content at index {index}, but no content with "
|
||||
f"that index currently exists!")
|
||||
# Delete the target index from both the content list and content records.
|
||||
self.content_list.pop(index)
|
||||
self.content_records.pop(index)
|
||||
self.num_contents -= 1
|
||||
|
||||
def remove_content_by_cid(self, cid: int) -> None:
|
||||
"""
|
||||
Removes the content with the specified Content ID from the ContentRegion and content records.
|
||||
|
||||
This will allow gaps to be left in content indices, however this should not cause any issues.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
cid : int
|
||||
The Content ID of the content you want to remove.
|
||||
"""
|
||||
try:
|
||||
index = self.get_index_from_cid(cid)
|
||||
except ValueError:
|
||||
raise ValueError(f"You are trying to remove content with Content ID {cid}, "
|
||||
f"but no content with that ID exists!")
|
||||
self.remove_content_by_index(index)
|
||||
|
||||
|
||||
@_dataclass
|
||||
class _SharedContentRecord:
|
||||
"""
|
||||
A _SharedContentRecord object used to store the data of a specific content stored in /shared1/. Private class used
|
||||
by the content module.
|
||||
|
||||
Attributes
|
||||
----------
|
||||
shared_id : str
|
||||
The incremental ID used to store the shared content.
|
||||
content_hash : bytes
|
||||
The SHA-1 hash of the shared content.
|
||||
"""
|
||||
shared_id: str
|
||||
content_hash: bytes
|
||||
|
||||
|
||||
class SharedContentMap:
|
||||
"""
|
||||
A SharedContentMap object to parse and edit the content.map file stored in /shared1/ on the Wii's NAND. This file is
|
||||
used to keep track of all shared contents installed on the console.
|
||||
|
||||
Attributes
|
||||
----------
|
||||
shared_records : List[_SharedContentRecord]
|
||||
The shared content records stored in content.map.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.shared_records: List[_SharedContentRecord] = []
|
||||
|
||||
def load(self, content_map: bytes) -> None:
|
||||
"""
|
||||
Loads the raw content map and parses the records in it.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
content_map : bytes
|
||||
The data of a content.map file.
|
||||
"""
|
||||
# Sanity check to ensure the length is divisible by 28 bytes. If it isn't, then it is malformed.
|
||||
if (len(content_map) % 28) != 0:
|
||||
raise ValueError("The provided content map appears to be corrupted!")
|
||||
entry_count = len(content_map) // 28
|
||||
with io.BytesIO(content_map) as map_data:
|
||||
for i in range(entry_count):
|
||||
shared_id = str(map_data.read(8).decode())
|
||||
content_hash = binascii.hexlify(map_data.read(20))
|
||||
self.shared_records.append(_SharedContentRecord(shared_id, content_hash))
|
||||
|
||||
def dump(self) -> bytes:
|
||||
"""
|
||||
Dumps the SharedContentMap object back into a content.map file.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bytes
|
||||
The raw data of the content.map file.
|
||||
"""
|
||||
map_data = b''
|
||||
for record in self.shared_records:
|
||||
map_data += record.shared_id.encode()
|
||||
map_data += binascii.unhexlify(record.content_hash)
|
||||
return map_data
|
||||
|
||||
def add_content(self, content_hash: str | bytes) -> str:
|
||||
"""
|
||||
Adds a new shared content SHA-1 hash to the content map and returns the file name assigned to that hash.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
content_hash : str, bytes
|
||||
The SHA-1 hash of the new shared content.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
The filename assigned to the provided content hash.
|
||||
"""
|
||||
if type(content_hash) is bytes:
|
||||
# This catches the format b'GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG'
|
||||
if len(content_hash) == 40:
|
||||
content_hash_converted = content_hash
|
||||
# This catches the format
|
||||
# b'\xGG\xGG\xGG\xGG\xGG\xGG\xGG\xGG\xGG\xGG\xGG\xGG\xGG\xGG\xGG\xGG\xGG\xGG\xGG\xGG'
|
||||
elif len(content_hash) == 20:
|
||||
content_hash_converted = binascii.hexlify(content_hash)
|
||||
# If it isn't one of those lengths, it cannot possibly be valid, so reject it.
|
||||
else:
|
||||
raise ValueError("SHA-1 hash is not valid!")
|
||||
# Allow for a string like "GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG"
|
||||
elif type(content_hash) is str:
|
||||
content_hash_converted = content_hash.encode()
|
||||
# If the hash isn't bytes or a string, it isn't valid and is rejected.
|
||||
else:
|
||||
self.content_list[index] = enc_content
|
||||
raise TypeError("SHA-1 hash type is not valid! It must be either type str or bytes.")
|
||||
|
||||
# Generate the file name for the new shared content by incrementing the highest name by 1. Thank you, Nintendo,
|
||||
# for not just storing these as integers like you did EVERYWHERE else.
|
||||
try:
|
||||
maximum_index = int(self.shared_records[-1].shared_id, 16)
|
||||
new_index = f"{maximum_index + 1:08X}".lower()
|
||||
except IndexError:
|
||||
new_index = f"{0:08X}"
|
||||
self.shared_records.append(_SharedContentRecord(new_index, content_hash_converted))
|
||||
return new_index
|
||||
|
||||
@@ -2,13 +2,35 @@
|
||||
# https://github.com/NinjaCheetah/libWiiPy
|
||||
|
||||
import struct
|
||||
import binascii
|
||||
from .commonkeys import get_common_key
|
||||
from ..shared import convert_tid_to_iv
|
||||
|
||||
from Crypto.Cipher import AES
|
||||
from Crypto.Cipher import AES as _AES
|
||||
|
||||
|
||||
def decrypt_title_key(title_key_enc: bytes, common_key_index: int, title_id: bytes | str) -> bytes:
|
||||
def _convert_tid_to_iv(title_id: str | bytes) -> bytes:
|
||||
# Converts a Title ID in various formats into the format required to act as an IV. Private function used by other
|
||||
# crypto functions.
|
||||
title_key_iv = b''
|
||||
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:
|
||||
title_key_iv = title_id
|
||||
# If it isn't one of those lengths, it cannot possibly be valid, so reject it.
|
||||
else:
|
||||
raise ValueError("Title ID is not valid!")
|
||||
# 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, dev=False) -> bytes:
|
||||
"""
|
||||
Gets the decrypted version of the encrypted Title Key provided.
|
||||
|
||||
@@ -22,6 +44,8 @@ def decrypt_title_key(title_key_enc: bytes, common_key_index: int, title_id: byt
|
||||
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.
|
||||
dev : bool
|
||||
Whether the Title Key is encrypted with the development key or not.
|
||||
|
||||
Returns
|
||||
-------
|
||||
@@ -29,19 +53,19 @@ def decrypt_title_key(title_key_enc: bytes, common_key_index: int, title_id: byt
|
||||
The decrypted Title Key.
|
||||
"""
|
||||
# Load the correct common key for the title.
|
||||
common_key = get_common_key(common_key_index)
|
||||
common_key = get_common_key(common_key_index, dev)
|
||||
# Convert the IV into the correct format based on the type provided.
|
||||
title_key_iv = convert_tid_to_iv(title_id)
|
||||
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)
|
||||
aes = _AES.new(common_key, _AES.MODE_CBC, title_key_iv)
|
||||
# Decrypt the Title Key using the AES object.
|
||||
title_key = aes.decrypt(title_key_enc)
|
||||
return title_key
|
||||
|
||||
|
||||
def encrypt_title_key(title_key_dec: bytes, common_key_index: int, title_id: bytes | str) -> bytes:
|
||||
def encrypt_title_key(title_key_dec: bytes, common_key_index: int, title_id: bytes | str, dev=False) -> bytes:
|
||||
"""
|
||||
Encrypts the provided Title Key with the selected common key.
|
||||
|
||||
@@ -55,6 +79,8 @@ def encrypt_title_key(title_key_dec: bytes, common_key_index: int, title_id: byt
|
||||
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.
|
||||
dev : bool
|
||||
Whether the Title Key is encrypted with the development key or not.
|
||||
|
||||
Returns
|
||||
-------
|
||||
@@ -62,13 +88,13 @@ def encrypt_title_key(title_key_dec: bytes, common_key_index: int, title_id: byt
|
||||
An encrypted Title Key.
|
||||
"""
|
||||
# Load the correct common key for the title.
|
||||
common_key = get_common_key(common_key_index)
|
||||
common_key = get_common_key(common_key_index, dev)
|
||||
# Convert the IV into the correct format based on the type provided.
|
||||
title_key_iv = convert_tid_to_iv(title_id)
|
||||
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)
|
||||
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
|
||||
@@ -105,7 +131,7 @@ def decrypt_content(content_enc, title_key, content_index, content_length) -> by
|
||||
if (len(content_enc) % 16) != 0:
|
||||
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.
|
||||
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.
|
||||
content_dec = aes.decrypt(content_enc)
|
||||
# Trim additional bytes that may have been added so the content is the correct size.
|
||||
@@ -144,7 +170,7 @@ def encrypt_content(content_dec, title_key, content_index) -> bytes:
|
||||
if (len(content_dec) % 16) != 0:
|
||||
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.
|
||||
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.
|
||||
content_enc = aes.encrypt(content_dec)
|
||||
# Trim down the encrypted content.
|
||||
|
||||
252
src/libWiiPy/title/iospatcher.py
Normal file
252
src/libWiiPy/title/iospatcher.py
Normal file
@@ -0,0 +1,252 @@
|
||||
# "title/iospatcher.py" from libWiiPy by NinjaCheetah & Contributors
|
||||
# https://github.com/NinjaCheetah/libWiiPy
|
||||
#
|
||||
# Module for applying patches to IOS WADs via a Title().
|
||||
|
||||
import io
|
||||
from .title import Title
|
||||
|
||||
|
||||
class IOSPatcher:
|
||||
"""
|
||||
An IOSPatcher object that allows for applying patches to IOS WADs loaded into Title objects.
|
||||
|
||||
Attributes
|
||||
----------
|
||||
title : Title
|
||||
The loaded Title object to be patched.
|
||||
es_module_index : int
|
||||
The content index that ES resides in and where ES patches are applied.
|
||||
dip_module_index : int
|
||||
The content index that DIP resides in and where DIP patches are applied. -1 if DIP patches are not applied.
|
||||
"""
|
||||
def __init__(self):
|
||||
self.title: Title = Title()
|
||||
self.es_module_index: int = -1
|
||||
self.dip_module_index: int = -1
|
||||
|
||||
def load(self, title: Title) -> None:
|
||||
"""
|
||||
Loads a Title object containing an IOS WAD and locates the content containing the ES module that needs to be
|
||||
patched.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
title : Title
|
||||
A Title object containing the IOS to be patched.
|
||||
"""
|
||||
# Check to ensure that this Title contains IOS. IOS always has a TID high of 00000001, and any TID low after
|
||||
# 00000002.
|
||||
tid = title.tmd.title_id
|
||||
if tid[:8] != "00000001" or tid[8:] == "00000001" or tid[8:] == "00000002":
|
||||
raise ValueError("This Title does not contain an IOS! Cannot load Title for patching.")
|
||||
|
||||
# Now that we know this is IOS, we need to go ahead and check all of its contents until we find the one that
|
||||
# contains the ES module, since that's what we're patching.
|
||||
es_content_index = -1
|
||||
for content in range(len(title.content.content_records)):
|
||||
target_content = title.get_content_by_index(title.content.content_records[content].index)
|
||||
es_offset = target_content.find(b'\x45\x53\x3A') # This is looking for "ES:"
|
||||
if es_offset != -1:
|
||||
es_content_index = title.content.content_records[content].index
|
||||
break
|
||||
|
||||
# If we get here with no content index, then ES wasn't found. That probably means that this isn't IOS.
|
||||
if es_content_index == -1:
|
||||
raise Exception("ES module could not be found! Please ensure that this is an intact copy of an IOS.")
|
||||
|
||||
self.title = title
|
||||
self.es_module_index = es_content_index
|
||||
|
||||
def dump(self) -> Title:
|
||||
"""
|
||||
Returns the patched Title object.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Title
|
||||
The patched Title object.
|
||||
"""
|
||||
return self.title
|
||||
|
||||
def patch_all(self) -> int:
|
||||
"""
|
||||
Applies all patches to patch in fakesigning, ES_Identify access, /dev/flash access, and the version downgrading
|
||||
patch.
|
||||
|
||||
Returns
|
||||
-------
|
||||
int
|
||||
The number of patches successfully applied.
|
||||
"""
|
||||
patch_count = 0
|
||||
patch_count += self.patch_fakesigning()
|
||||
patch_count += self.patch_es_identify()
|
||||
patch_count += self.patch_nand_access()
|
||||
patch_count += self.patch_version_downgrading()
|
||||
return patch_count
|
||||
|
||||
def patch_fakesigning(self) -> int:
|
||||
"""
|
||||
Patches the trucha/fakesigning bug back into the IOS' ES module to allow it to accept fakesigned TMDs and
|
||||
Tickets.
|
||||
|
||||
Returns
|
||||
-------
|
||||
int
|
||||
The number of patches successfully applied.
|
||||
"""
|
||||
if self.es_module_index == -1:
|
||||
raise Exception("No valid IOS is loaded! Patching cannot continue.")
|
||||
|
||||
target_content = self.title.get_content_by_index(self.es_module_index)
|
||||
|
||||
patch_count = 0
|
||||
patch_sequences = [b'\x20\x07\x23\xa2', b'\x20\x07\x4b\x0b']
|
||||
for sequence in patch_sequences:
|
||||
start_offset = target_content.find(sequence)
|
||||
if start_offset != -1:
|
||||
with io.BytesIO(target_content) as content_data:
|
||||
content_data.seek(start_offset + 1)
|
||||
content_data.write(b'\x00')
|
||||
content_data.seek(0)
|
||||
target_content = content_data.read()
|
||||
patch_count += 1
|
||||
|
||||
self.title.set_content(target_content, self.es_module_index)
|
||||
|
||||
return patch_count
|
||||
|
||||
def patch_es_identify(self) -> int:
|
||||
"""
|
||||
Patches the ability to call ES_Identify back into the IOS' ES module to allow for changing the permissions of a
|
||||
title.
|
||||
|
||||
Returns
|
||||
-------
|
||||
int
|
||||
The number of patches successfully applied.
|
||||
"""
|
||||
if self.es_module_index == -1:
|
||||
raise Exception("No valid IOS is loaded! Patching cannot continue.")
|
||||
|
||||
target_content = self.title.get_content_by_index(self.es_module_index)
|
||||
|
||||
patch_count = 0
|
||||
patch_sequence = b'\x28\x03\xd1\x23'
|
||||
start_offset = target_content.find(patch_sequence)
|
||||
if start_offset != -1:
|
||||
with io.BytesIO(target_content) as content_data:
|
||||
content_data.seek(start_offset + 2)
|
||||
content_data.write(b'\x00\x00')
|
||||
content_data.seek(0)
|
||||
target_content = content_data.read()
|
||||
patch_count += 1
|
||||
|
||||
self.title.set_content(target_content, self.es_module_index)
|
||||
|
||||
return patch_count
|
||||
|
||||
def patch_nand_access(self) -> int:
|
||||
"""
|
||||
Patches the ability to directly access /dev/flash back into the IOS' ES module to allow for raw access to the
|
||||
Wii's filesystem.
|
||||
|
||||
Returns
|
||||
-------
|
||||
int
|
||||
The number of patches successfully applied.
|
||||
"""
|
||||
if self.es_module_index == -1:
|
||||
raise Exception("No valid IOS is loaded! Patching cannot continue.")
|
||||
|
||||
target_content = self.title.get_content_by_index(self.es_module_index)
|
||||
|
||||
patch_count = 0
|
||||
patch_sequence = b'\x42\x8b\xd0\x01\x25\x66'
|
||||
start_offset = target_content.find(patch_sequence)
|
||||
if start_offset != -1:
|
||||
with io.BytesIO(target_content) as content_data:
|
||||
content_data.seek(start_offset + 2)
|
||||
content_data.write(b'\xe0')
|
||||
content_data.seek(0)
|
||||
target_content = content_data.read()
|
||||
patch_count += 1
|
||||
|
||||
self.title.set_content(target_content, self.es_module_index)
|
||||
|
||||
return patch_count
|
||||
|
||||
def patch_version_downgrading(self) -> int:
|
||||
"""
|
||||
Patches the ability to downgrade installed titles into IOS' ES module.
|
||||
|
||||
Returns
|
||||
-------
|
||||
int
|
||||
The number of patches successfully applied.
|
||||
"""
|
||||
if self.es_module_index == -1:
|
||||
raise Exception("No valid IOS is loaded! Patching cannot continue.")
|
||||
|
||||
target_content = self.title.get_content_by_index(self.es_module_index)
|
||||
|
||||
patch_count = 0
|
||||
patch_sequence = b'\xd2\x01\x4e\x56'
|
||||
start_offset = target_content.find(patch_sequence)
|
||||
if start_offset != -1:
|
||||
with io.BytesIO(target_content) as content_data:
|
||||
content_data.seek(start_offset)
|
||||
content_data.write(b'\xe0')
|
||||
content_data.seek(0)
|
||||
target_content = content_data.read()
|
||||
patch_count += 1
|
||||
|
||||
self.title.set_content(target_content, self.es_module_index)
|
||||
|
||||
return patch_count
|
||||
|
||||
def patch_drive_inquiry(self) -> int:
|
||||
"""
|
||||
Patches out IOS' drive inquiry on startup, allowing IOS to load without a disc drive. Only required/useful if
|
||||
you do not have a disc drive connected to your console.
|
||||
|
||||
This drive inquiry patch is EXPERIMENTAL, and may introduce unexpected side effects on some consoles.
|
||||
|
||||
Returns
|
||||
-------
|
||||
int
|
||||
The number of patches successfully applied.
|
||||
"""
|
||||
if self.es_module_index == -1:
|
||||
raise Exception("No valid IOS is loaded! Patching cannot continue.")
|
||||
|
||||
# This patch is applied to the DIP module rather than to ES, so we need to search the contents for the right one
|
||||
# first.
|
||||
for content in range(len(self.title.content.content_records)):
|
||||
target_content = self.title.get_content_by_index(self.title.content.content_records[content].index)
|
||||
dip_offset = target_content.find(b'\x44\x49\x50\x3a') # This is looking for "DIP:"
|
||||
if dip_offset != -1:
|
||||
self.dip_module_index = self.title.content.content_records[content].index
|
||||
break
|
||||
|
||||
# If we get here with no content index, then DIP wasn't found. That probably means that this isn't IOS.
|
||||
if self.dip_module_index == -1:
|
||||
raise Exception("DIP module could not be found! Please ensure that this is an intact copy of an IOS.")
|
||||
|
||||
target_content = self.title.get_content_by_index(self.dip_module_index)
|
||||
|
||||
patch_count = 0
|
||||
patch_sequence = b'\x49\x4c\x23\x90\x68\x0a' # 49 4c 23 90 68 0a
|
||||
start_offset = target_content.find(patch_sequence)
|
||||
if start_offset != -1:
|
||||
with io.BytesIO(target_content) as content_data:
|
||||
content_data.seek(start_offset)
|
||||
content_data.write(b'\x20\x00\xe5\x38')
|
||||
content_data.seek(0)
|
||||
target_content = content_data.read()
|
||||
patch_count += 1
|
||||
|
||||
self.title.set_content(target_content, self.dip_module_index)
|
||||
|
||||
return patch_count
|
||||
@@ -10,7 +10,7 @@ from .title import Title
|
||||
from .tmd import TMD
|
||||
from .ticket import Ticket
|
||||
|
||||
nus_endpoint = ["http://nus.cdn.shop.wii.com/ccs/download/", "http://ccs.cdn.wup.shop.nintendo.net/ccs/download/"]
|
||||
_nus_endpoint = ["http://nus.cdn.shop.wii.com/ccs/download/", "http://ccs.cdn.wup.shop.nintendo.net/ccs/download/"]
|
||||
|
||||
|
||||
def download_title(title_id: str, title_version: int = None, wiiu_endpoint: bool = False) -> Title:
|
||||
@@ -68,9 +68,9 @@ def download_tmd(title_id: str, title_version: int = None, wiiu_endpoint: bool =
|
||||
# Build the download URL. The structure is download/<TID>/tmd for latest and download/<TID>/tmd.<version> for
|
||||
# when a specific version is requested.
|
||||
if wiiu_endpoint is False:
|
||||
tmd_url = nus_endpoint[0] + title_id + "/tmd"
|
||||
tmd_url = _nus_endpoint[0] + title_id + "/tmd"
|
||||
else:
|
||||
tmd_url = nus_endpoint[1] + title_id + "/tmd"
|
||||
tmd_url = _nus_endpoint[1] + title_id + "/tmd"
|
||||
# Add the version to the URL if one was specified.
|
||||
if title_version is not None:
|
||||
tmd_url += "." + str(title_version)
|
||||
@@ -109,9 +109,9 @@ def download_ticket(title_id: str, wiiu_endpoint: bool = False) -> bytes:
|
||||
# Build the download URL. The structure is download/<TID>/cetk, and cetk will only exist if this is a free
|
||||
# title.
|
||||
if wiiu_endpoint is False:
|
||||
ticket_url = nus_endpoint[0] + title_id + "/cetk"
|
||||
ticket_url = _nus_endpoint[0] + title_id + "/cetk"
|
||||
else:
|
||||
ticket_url = nus_endpoint[1] + title_id + "/cetk"
|
||||
ticket_url = _nus_endpoint[1] + title_id + "/cetk"
|
||||
# Make the request.
|
||||
ticket_request = requests.get(url=ticket_url, headers={'User-Agent': 'wii libnup/1.0'}, stream=True)
|
||||
if ticket_request.status_code != 200:
|
||||
@@ -142,11 +142,11 @@ def download_cert(wiiu_endpoint: bool = False) -> bytes:
|
||||
"""
|
||||
# Download the TMD and cetk for the System Menu 4.3U.
|
||||
if wiiu_endpoint is False:
|
||||
tmd_url = nus_endpoint[0] + "0000000100000002/tmd.513"
|
||||
cetk_url = nus_endpoint[0] + "0000000100000002/cetk"
|
||||
tmd_url = _nus_endpoint[0] + "0000000100000002/tmd.513"
|
||||
cetk_url = _nus_endpoint[0] + "0000000100000002/cetk"
|
||||
else:
|
||||
tmd_url = nus_endpoint[1] + "0000000100000002/tmd.513"
|
||||
cetk_url = nus_endpoint[1] + "0000000100000002/cetk"
|
||||
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.
|
||||
@@ -186,9 +186,9 @@ def download_content(title_id: str, content_id: int, wiiu_endpoint: bool = False
|
||||
if len(content_id_hex) < 2:
|
||||
content_id_hex = "0" + content_id_hex
|
||||
if wiiu_endpoint is False:
|
||||
content_url = nus_endpoint[0] + title_id + "/000000" + content_id_hex
|
||||
content_url = _nus_endpoint[0] + title_id + "/000000" + content_id_hex
|
||||
else:
|
||||
content_url = nus_endpoint[1] + title_id + "/000000" + content_id_hex
|
||||
content_url = _nus_endpoint[1] + title_id + "/000000" + content_id_hex
|
||||
# Make the request.
|
||||
content_request = requests.get(url=content_url, headers={'User-Agent': 'wii libnup/1.0'}, stream=True)
|
||||
if content_request.status_code != 200:
|
||||
|
||||
@@ -5,9 +5,33 @@
|
||||
|
||||
import io
|
||||
import binascii
|
||||
import hashlib
|
||||
from dataclasses import dataclass as _dataclass
|
||||
from .crypto import decrypt_title_key
|
||||
from ..types import TitleLimit
|
||||
from typing import List
|
||||
from .util import title_ver_standard_to_dec
|
||||
|
||||
|
||||
@_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:
|
||||
@@ -16,6 +40,9 @@ class Ticket:
|
||||
|
||||
Attributes
|
||||
----------
|
||||
is_dev : bool
|
||||
Whether this Ticket is signed for development or not, and whether the Title Key is encrypted for development
|
||||
or not.
|
||||
signature : bytes
|
||||
The signature applied to the ticket.
|
||||
ticket_version : int
|
||||
@@ -32,6 +59,8 @@ class Ticket:
|
||||
The index of the common key required to decrypt this ticket's Title Key.
|
||||
"""
|
||||
def __init__(self):
|
||||
# If this is a dev ticket
|
||||
self.is_dev: bool = False # Defaults to false, set to true during load if this ticket is using dev certs.
|
||||
# Signature blob header
|
||||
self.signature_type: bytes = b'' # Type of signature, always 0x10001 for RSA-2048
|
||||
self.signature: bytes = b'' # Actual signature data
|
||||
@@ -43,16 +72,17 @@ class Ticket:
|
||||
self.ticket_id: bytes = b'' # Used as the IV when decrypting the title key for console-specific title installs.
|
||||
self.console_id: int = 0 # ID of the console that the ticket was issued for.
|
||||
self.title_id: bytes = b'' # TID/IV used for AES-CBC encryption.
|
||||
self.title_id_str: str = "" # TID in string form for comparing against the TMD.
|
||||
self.unknown1: bytes = b'' # Some unknown data, not always the same so reading it just in case.
|
||||
self.title_version: int = 0 # Version of the ticket's associated title.
|
||||
self.permitted_titles: bytes = b'' # Permitted titles mask
|
||||
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.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.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
|
||||
# TODO: Write in v1 ticket attributes here. This code can currently only handle v0 tickets, and will reject v1.
|
||||
|
||||
@@ -100,17 +130,12 @@ class Ticket:
|
||||
# Title ID.
|
||||
ticket_data.seek(0x1DC)
|
||||
self.title_id = binascii.hexlify(ticket_data.read(8))
|
||||
# Title ID (as a string).
|
||||
self.title_id_str = str(self.title_id.decode())
|
||||
# Unknown data 1.
|
||||
ticket_data.seek(0x1E4)
|
||||
self.unknown1 = ticket_data.read(2)
|
||||
# Title version.
|
||||
ticket_data.seek(0x1E6)
|
||||
title_version_high = int.from_bytes(ticket_data.read(1)) * 256
|
||||
ticket_data.seek(0x1E7)
|
||||
title_version_low = int.from_bytes(ticket_data.read(1))
|
||||
self.title_version = title_version_high + title_version_low
|
||||
self.title_version = int.from_bytes(ticket_data.read(2))
|
||||
# Permitted titles mask.
|
||||
ticket_data.seek(0x1E8)
|
||||
self.permitted_titles = ticket_data.read(4)
|
||||
@@ -134,12 +159,16 @@ class Ticket:
|
||||
for limit in range(0, 8):
|
||||
limit_type = 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))
|
||||
# Check certs to see if this is a retail or dev ticket. Treats unknown certs as being retail for now.
|
||||
if self.signature_issuer.find("Root-CA00000002-XS00000006") != -1:
|
||||
self.is_dev = True
|
||||
else:
|
||||
self.is_dev = False
|
||||
|
||||
def dump(self) -> bytes:
|
||||
"""
|
||||
Dumps the Ticket object back into bytes. This also sets the raw Ticket attribute of Ticket object to the
|
||||
dumped data, and triggers load() again to ensure that the raw data and object match.
|
||||
Dumps the Ticket object back into bytes.
|
||||
|
||||
Returns
|
||||
-------
|
||||
@@ -174,10 +203,7 @@ class Ticket:
|
||||
# Unknown data 1.
|
||||
ticket_data += self.unknown1
|
||||
# Title version.
|
||||
title_version_high = round(self.title_version / 256)
|
||||
ticket_data += int.to_bytes(title_version_high, 1)
|
||||
title_version_low = self.title_version % 256
|
||||
ticket_data += int.to_bytes(title_version_low, 1)
|
||||
ticket_data += int.to_bytes(self.title_version, 2)
|
||||
# Permitted titles mask.
|
||||
ticket_data += self.permitted_titles
|
||||
# Permit mask.
|
||||
@@ -202,6 +228,61 @@ class Ticket:
|
||||
ticket_data += title_limit_data
|
||||
return ticket_data
|
||||
|
||||
def fakesign(self) -> None:
|
||||
"""
|
||||
Fakesigns this Ticket for the trucha bug.
|
||||
|
||||
This is done by brute-forcing a Ticket body hash starting with 00, causing it to pass signature verification on
|
||||
older IOS versions that incorrectly check the hash using strcmp() instead of memcmp(). The signature will also
|
||||
be erased and replaced with all NULL bytes.
|
||||
|
||||
The hash is brute-forced by using the first two bytes of an unused section of the Ticket as a 16-bit integer,
|
||||
and incrementing that value by 1 until an appropriate hash is found.
|
||||
|
||||
This modifies the Ticket object in place. You will need to call this method after any changes, and before
|
||||
dumping the Ticket object back into bytes.
|
||||
"""
|
||||
# Clear the signature, so that the hash derived from it is guaranteed to always be
|
||||
# '0000000000000000000000000000000000000000'.
|
||||
self.signature = b'\x00' * 256
|
||||
current_int = 0
|
||||
test_hash = ''
|
||||
while test_hash[:2] != '00':
|
||||
current_int += 1
|
||||
# We're using the first 2 bytes of this unused region of the Ticket as a 16-bit integer, and incrementing
|
||||
# that to brute-force the hash we need.
|
||||
data_to_edit = self.unknown2
|
||||
data_to_edit = int.to_bytes(current_int, 2) + data_to_edit[2:]
|
||||
self.unknown2 = data_to_edit
|
||||
# Trim off the first 320 bytes, because we're only looking for the hash of the Ticket's body.
|
||||
# This is a try-except because an OverflowError will be thrown if the number being used to brute-force the
|
||||
# hash gets too big, as it is only a 16-bit integer. If that happens, then fakesigning has failed.
|
||||
try:
|
||||
test_hash = hashlib.sha1(self.dump()[320:]).hexdigest()
|
||||
except OverflowError:
|
||||
raise Exception("An error occurred during fakesigning. Ticket could not be fakesigned!")
|
||||
|
||||
def get_is_fakesigned(self) -> bool:
|
||||
"""
|
||||
Checks the Ticket object to see if it is currently fakesigned. For a description of fakesigning, refer to the
|
||||
fakesign() method.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bool:
|
||||
True if the Ticket is fakesigned, False otherwise.
|
||||
|
||||
See Also
|
||||
--------
|
||||
libWiiPy.title.ticket.Ticket.fakesign()
|
||||
"""
|
||||
if self.signature != b'\x00' * 256:
|
||||
return False
|
||||
test_hash = hashlib.sha1(self.dump()[320:]).hexdigest()
|
||||
if test_hash[:2] != '00':
|
||||
return False
|
||||
return True
|
||||
|
||||
def get_title_id(self) -> str:
|
||||
"""
|
||||
Gets the Title ID of the ticket's associated title.
|
||||
@@ -225,7 +306,7 @@ class Ticket:
|
||||
|
||||
See Also
|
||||
--------
|
||||
commonkeys.get_common_key
|
||||
libWiiPy.title.commonkeys.get_common_key
|
||||
"""
|
||||
match self.common_key_index:
|
||||
case 0:
|
||||
@@ -244,12 +325,13 @@ class Ticket:
|
||||
bytes
|
||||
The decrypted title key.
|
||||
"""
|
||||
title_key = decrypt_title_key(self.title_key_enc, self.common_key_index, self.title_id)
|
||||
title_key = decrypt_title_key(self.title_key_enc, self.common_key_index, self.title_id, self.is_dev)
|
||||
return title_key
|
||||
|
||||
def set_title_id(self, title_id) -> None:
|
||||
"""
|
||||
Sets the Title ID of the title in the Ticket.
|
||||
Sets the Title ID property of the Ticket. Recommended over setting the property directly because of input
|
||||
validation.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
@@ -258,5 +340,34 @@ class Ticket:
|
||||
"""
|
||||
if len(title_id) != 16:
|
||||
raise ValueError("Invalid Title ID! Title IDs must be 8 bytes long.")
|
||||
self.title_id_str = title_id
|
||||
self.title_id = binascii.unhexlify(title_id)
|
||||
self.title_id = title_id.encode()
|
||||
|
||||
def set_title_version(self, new_version: str | int) -> None:
|
||||
"""
|
||||
Sets the version of the title in the Ticket. Recommended over setting the data directly because of input
|
||||
validation.
|
||||
|
||||
Accepts either standard form (vX.X) as a string or decimal form (vXXX) as an integer.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
new_version : str, int
|
||||
The new version of the title. See description for valid formats.
|
||||
"""
|
||||
if type(new_version) is str:
|
||||
# Validate string input is in the correct format, then validate that the version isn't higher than v255.0.
|
||||
# If checks pass, convert to decimal form and set that as the title version.
|
||||
version_str_split = new_version.split(".")
|
||||
if len(version_str_split) != 2:
|
||||
raise ValueError("Title version is not valid! String version must be entered in format \"X.X\".")
|
||||
if int(version_str_split[0]) > 255 or int(version_str_split[1]) > 255:
|
||||
raise ValueError("Title version is not valid! String version number cannot exceed v255.255.")
|
||||
version_converted = title_ver_standard_to_dec(new_version, str(self.title_id.decode()))
|
||||
self.title_version = version_converted
|
||||
elif type(new_version) is int:
|
||||
# Validate that the version isn't higher than v65280. If the check passes, set that as the title version.
|
||||
if new_version > 65535:
|
||||
raise ValueError("Title version is not valid! Integer version number cannot exceed v65535.")
|
||||
self.title_version = new_version
|
||||
else:
|
||||
raise TypeError("Title version type is not valid! Type must be either integer or string.")
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
#
|
||||
# See https://wiibrew.org/wiki/Title for details about how titles are formatted
|
||||
|
||||
import math
|
||||
from .content import ContentRegion
|
||||
from .ticket import Ticket
|
||||
from .tmd import TMD
|
||||
from .wad import WAD
|
||||
from .crypto import encrypt_title_key
|
||||
|
||||
|
||||
class Title:
|
||||
@@ -56,7 +58,7 @@ class Title:
|
||||
self.content.load(self.wad.get_content_data(), self.tmd.content_records)
|
||||
# Ensure that the Title IDs of the TMD and Ticket match before doing anything else. If they don't, throw an
|
||||
# error because clearly something strange has gone on with the WAD and editing it probably won't work.
|
||||
if self.tmd.title_id != self.ticket.title_id_str:
|
||||
if self.tmd.title_id != str(self.ticket.title_id.decode()):
|
||||
raise ValueError("The Title IDs of the TMD and Ticket in this WAD do not match. This WAD appears to be "
|
||||
"invalid.")
|
||||
|
||||
@@ -74,11 +76,15 @@ class Title:
|
||||
if self.tmd.title_id == "0000000100000001":
|
||||
self.wad.wad_type = "ib"
|
||||
# Dump the TMD and set it in the WAD.
|
||||
# This requires updating the content records and number of contents in the TMD first.
|
||||
self.tmd.content_records = self.content.content_records # This may not be needed because it's a ref already
|
||||
self.tmd.num_contents = len(self.content.content_records)
|
||||
self.wad.set_tmd_data(self.tmd.dump())
|
||||
# Dump the Ticket and set it in the WAD.
|
||||
self.wad.set_ticket_data(self.ticket.dump())
|
||||
# Dump the ContentRegion and set it in the WAD.
|
||||
self.wad.set_content_data(self.content.dump())
|
||||
content_data, content_size = self.content.dump()
|
||||
self.wad.set_content_data(content_data, content_size)
|
||||
return self.wad.dump()
|
||||
|
||||
def load_tmd(self, tmd: bytes) -> None:
|
||||
@@ -113,12 +119,14 @@ class Title:
|
||||
"""
|
||||
if not self.tmd.content_records:
|
||||
ValueError("No TMD appears to have been loaded, so content records cannot be read from it.")
|
||||
# Load the content records into the ContentRegion object.
|
||||
# Load the content records into the ContentRegion object, and update the number of contents.
|
||||
self.content.content_records = self.tmd.content_records
|
||||
self.content.num_contents = self.tmd.num_contents
|
||||
|
||||
def set_title_id(self, title_id: str) -> None:
|
||||
"""
|
||||
Sets the Title ID of the title in both the TMD and Ticket.
|
||||
Sets the Title ID of the title in both the TMD and Ticket. This also re-encrypts the Title Key as the Title Key
|
||||
is used as the IV for decrypting it.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
@@ -128,9 +136,27 @@ class Title:
|
||||
if len(title_id) != 16:
|
||||
raise ValueError("Invalid Title ID! Title IDs must be 8 bytes long.")
|
||||
self.tmd.set_title_id(title_id)
|
||||
title_key_decrypted = self.ticket.get_title_key()
|
||||
self.ticket.set_title_id(title_id)
|
||||
title_key_encrypted = encrypt_title_key(title_key_decrypted, self.ticket.common_key_index, title_id,
|
||||
self.ticket.is_dev)
|
||||
self.ticket.title_key_enc = title_key_encrypted
|
||||
|
||||
def get_content_by_index(self, index: id) -> bytes:
|
||||
def set_title_version(self, title_version: str | int) -> None:
|
||||
"""
|
||||
Sets the version of the title in both the TMD and Ticket.
|
||||
|
||||
Accepts either standard form (vX.X) as a string or decimal form (vXXX) as an integer.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
title_version : str, int
|
||||
The new version of the title. See description for valid formats.
|
||||
"""
|
||||
self.tmd.set_title_version(title_version)
|
||||
self.ticket.set_title_version(title_version)
|
||||
|
||||
def get_content_by_index(self, index: id, skip_hash=False) -> bytes:
|
||||
"""
|
||||
Gets an individual content from the content region based on the provided index, in decrypted form.
|
||||
|
||||
@@ -138,19 +164,18 @@ class Title:
|
||||
----------
|
||||
index : int
|
||||
The index of the content you want to get.
|
||||
skip_hash : bool, optional
|
||||
Skip the hash check and return the content regardless of its hash. Defaults to false.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bytes
|
||||
The decrypted content listed in the content record.
|
||||
"""
|
||||
# Load the Title Key from the Ticket.
|
||||
title_key = self.ticket.get_title_key()
|
||||
# Get the decrypted content and return it.
|
||||
dec_content = self.content.get_content_by_index(index, title_key)
|
||||
dec_content = self.content.get_content_by_index(index, self.ticket.get_title_key(), skip_hash)
|
||||
return dec_content
|
||||
|
||||
def get_content_by_cid(self, cid: int) -> bytes:
|
||||
def get_content_by_cid(self, cid: int, skip_hash=False) -> bytes:
|
||||
"""
|
||||
Gets an individual content from the content region based on the provided Content ID, in decrypted form.
|
||||
|
||||
@@ -158,33 +183,83 @@ class Title:
|
||||
----------
|
||||
cid : int
|
||||
The Content ID of the content you want to get. Expected to be in decimal form.
|
||||
skip_hash : bool, optional
|
||||
Skip the hash check and return the content regardless of its hash. Defaults to false.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bytes
|
||||
The decrypted content listed in the content record.
|
||||
"""
|
||||
# Load the Title Key from the Ticket.
|
||||
title_key = self.ticket.get_title_key()
|
||||
# Get the decrypted content and return it.
|
||||
dec_content = self.content.get_content_by_cid(cid, title_key)
|
||||
dec_content = self.content.get_content_by_cid(cid, self.ticket.get_title_key(), skip_hash)
|
||||
return dec_content
|
||||
|
||||
def set_enc_content(self, enc_content: bytes, cid: int, index: int, content_type: int, content_size: int,
|
||||
def get_title_size(self, absolute=False) -> int:
|
||||
"""
|
||||
Gets the installed size of the title, including the TMD and Ticket, in bytes. The "absolute" option determines
|
||||
whether shared content sizes should be included in the total size or not. This option defaults to False.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
absolute : bool, optional
|
||||
Whether shared contents should be included in the total size or not. Defaults to False.
|
||||
|
||||
Returns
|
||||
-------
|
||||
int
|
||||
The installed size of the title, in bytes.
|
||||
"""
|
||||
title_size = 0
|
||||
# Dumping and measuring the TMD and Ticket this way to ensure that any changes to them are measured properly.
|
||||
# Yes, the Ticket size should be a constant, but it's still good to check just in case.
|
||||
title_size += len(self.tmd.dump())
|
||||
title_size += len(self.ticket.dump())
|
||||
# For contents, get their sizes from the content records, because they store the intended sizes of the decrypted
|
||||
# contents, which are usually different from the encrypted sizes.
|
||||
for record in self.content.content_records:
|
||||
if record.content_type == 32769:
|
||||
if absolute:
|
||||
title_size += record.content_size
|
||||
else:
|
||||
title_size += record.content_size
|
||||
return title_size
|
||||
|
||||
def get_title_size_blocks(self, absolute=False) -> int:
|
||||
"""
|
||||
Gets the installed size of the title, including the TMD and Ticket, in the Wii's displayed "blocks" format. The
|
||||
"absolute" option determines whether shared content sizes should be included in the total size or not. This
|
||||
option defaults to False.
|
||||
|
||||
1 Wii block is equal to 128KiB, and if any amount of a block is used, the entire block is considered used.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
absolute : bool, optional
|
||||
Whether shared contents should be included in the total size or not. Defaults to False.
|
||||
|
||||
Returns
|
||||
-------
|
||||
int
|
||||
The installed size of the title, in blocks.
|
||||
"""
|
||||
title_size_bytes = self.get_title_size(absolute)
|
||||
blocks = math.ceil(title_size_bytes / 131072)
|
||||
return blocks
|
||||
|
||||
def add_enc_content(self, enc_content: bytes, cid: int, index: int, content_type: int, content_size: int,
|
||||
content_hash: bytes) -> None:
|
||||
"""
|
||||
Sets the provided index to a new content with the provided Content ID. Hashes and size of the content are
|
||||
set in the content record, with a new record being added if necessary. The TMD is also updated to match the new
|
||||
records.
|
||||
Adds a new encrypted content to the ContentRegion, and adds the provided Content ID, index, content type,
|
||||
content size, and content hash to a new record in the ContentRecord list.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
enc_content : bytes
|
||||
The new encrypted content to set.
|
||||
The new encrypted content to add.
|
||||
cid : int
|
||||
The Content ID to assign the new content in the content record.
|
||||
index : int
|
||||
The index to place the new content at.
|
||||
The index used when encrypting the new content.
|
||||
content_type : int
|
||||
The type of the new content.
|
||||
content_size : int
|
||||
@@ -192,44 +267,134 @@ class Title:
|
||||
content_hash : bytes
|
||||
The hash of the new encrypted content when decrypted.
|
||||
"""
|
||||
# Set the encrypted content.
|
||||
self.content.set_enc_content(enc_content, cid, index, content_type, content_size, content_hash)
|
||||
# Add the encrypted content.
|
||||
self.content.add_enc_content(enc_content, cid, index, content_type, content_size, content_hash)
|
||||
# Update the TMD to match.
|
||||
self.tmd.content_records = self.content.content_records
|
||||
|
||||
def set_content(self, dec_content: bytes, cid: int, index: int, content_type: int) -> None:
|
||||
def add_content(self, dec_content: bytes, cid: int, content_type: int) -> None:
|
||||
"""
|
||||
Sets the provided index to a new content with the provided Content ID. Hashes and size of the content are
|
||||
set in the content record, with a new record being added if necessary. The Title Key is sourced from this
|
||||
title's loaded ticket. The TMD is also updated to match the new records.
|
||||
Adds a new decrypted content to the end of the ContentRegion, and adds the provided Content ID, content type,
|
||||
content size, and content hash to a new record in the ContentRecord list. The index will be automatically
|
||||
assigned by incrementing the current highest index in the records.
|
||||
|
||||
This first gets the content hash and size from the provided data, and then encrypts the content with the
|
||||
Title Key before adding it to the ContentRegion.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
dec_content : bytes
|
||||
The new decrypted content to add.
|
||||
cid : int
|
||||
The Content ID to assign the new content in the content record.
|
||||
content_type : int
|
||||
The type of the new content.
|
||||
"""
|
||||
# Add the decrypted content.
|
||||
self.content.add_content(dec_content, cid, content_type, self.ticket.get_title_key())
|
||||
# Update the TMD to match.
|
||||
self.tmd.content_records = self.content.content_records
|
||||
|
||||
def set_enc_content(self, enc_content: bytes, index: int, content_size: int, content_hash: bytes, cid: int = None,
|
||||
content_type: int = None) -> None:
|
||||
"""
|
||||
Sets the content at the provided index to the provided new encrypted content. The provided hash and content size
|
||||
are set in the corresponding content record. A new Content ID or content type can also be specified, but if it
|
||||
isn't then the current values are preserved.
|
||||
|
||||
This also updates the content records in the TMD after the content is set.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
enc_content : bytes
|
||||
The new encrypted content to set.
|
||||
index : int
|
||||
The index to place the new content at.
|
||||
content_size : int
|
||||
The size of the new encrypted content when decrypted.
|
||||
content_hash : bytes
|
||||
The hash of the new encrypted content when decrypted.
|
||||
cid : int
|
||||
The Content ID to assign the new content in the content record.
|
||||
content_type : int
|
||||
The type of the new content.
|
||||
"""
|
||||
# Set the encrypted content.
|
||||
self.content.set_enc_content(enc_content, index, content_size, content_hash, cid, content_type)
|
||||
# Update the TMD to match.
|
||||
self.tmd.content_records = self.content.content_records
|
||||
|
||||
def set_content(self, dec_content: bytes, index: int, cid: int = None, content_type: int = None) -> None:
|
||||
"""
|
||||
Sets the content at the provided index to the provided new decrypted content. The hash and content size of this
|
||||
content will be generated and then set in the corresponding content record. A new Content ID or content type can
|
||||
also be specified, but if it isn't then the current values are preserved.
|
||||
|
||||
This also updates the content records in the TMD after the content is set.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
dec_content : bytes
|
||||
The new decrypted content to set.
|
||||
cid : int
|
||||
The Content ID to assign the new content in the content record.
|
||||
index : int
|
||||
The index to place the new content at.
|
||||
content_type : int
|
||||
cid : int, optional
|
||||
The Content ID to assign the new content in the content record.
|
||||
content_type : int, optional
|
||||
The type of the new content.
|
||||
"""
|
||||
# Set the decrypted content.
|
||||
self.content.set_content(dec_content, cid, index, content_type, self.ticket.get_title_key())
|
||||
self.content.set_content(dec_content, index, self.ticket.get_title_key(), cid, content_type)
|
||||
# Update the TMD to match.
|
||||
self.tmd.content_records = self.content.content_records
|
||||
|
||||
def load_content(self, dec_content: bytes, index: int) -> None:
|
||||
"""
|
||||
Loads the provided decrypted content into the content region at the specified index, but first checks to make
|
||||
sure it matches the record at that index before loading. This content will be encrypted when loaded.
|
||||
Loads the provided decrypted content into the ContentRegion at the specified index, but first checks to make
|
||||
sure that it matches the corresponding record. This content will then be encrypted using the title's Title Key
|
||||
before being loaded.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
dec_content : bytes
|
||||
The decrypted content to load.
|
||||
index : int
|
||||
The content index to load the content at.
|
||||
The index to load the content at.
|
||||
"""
|
||||
# Load the decrypted content.
|
||||
self.content.load_content(dec_content, index, self.ticket.get_title_key())
|
||||
|
||||
def fakesign(self) -> None:
|
||||
"""
|
||||
Fakesigns this Title for the trucha bug.
|
||||
|
||||
This is done by brute-forcing a TMD and Ticket body hash starting with 00, causing it to pass signature
|
||||
verification on older IOS versions that incorrectly check the hash using strcmp() instead of memcmp(). The TMD
|
||||
and Ticket signatures will also be erased and replaced with all NULL bytes.
|
||||
|
||||
This modifies the TMD and Ticket objects that are part of this Title in place. You will need to call this method
|
||||
after any changes to the TMD or Ticket, and before dumping the Title object into a WAD to ensure that the WAD
|
||||
is properly fakesigned.
|
||||
"""
|
||||
self.tmd.num_contents = self.content.num_contents # This needs to be updated in case it was changed
|
||||
self.tmd.fakesign()
|
||||
self.ticket.fakesign()
|
||||
|
||||
def get_is_fakesigned(self):
|
||||
"""
|
||||
Checks the Title object to see if it is currently fakesigned. This ensures that both the TMD and Ticket are
|
||||
fakesigned. For a description of fakesigning, refer to the fakesign() method.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bool:
|
||||
True if the Title is fakesigned, False otherwise.
|
||||
|
||||
See Also
|
||||
--------
|
||||
libWiiPy.title.title.Title.fakesign()
|
||||
"""
|
||||
if self.tmd.get_is_fakesigned and self.ticket.get_is_fakesigned():
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
@@ -5,9 +5,13 @@
|
||||
|
||||
import io
|
||||
import binascii
|
||||
import hashlib
|
||||
import struct
|
||||
from typing import List
|
||||
from ..types import ContentRecord
|
||||
from enum import IntEnum as _IntEnum
|
||||
from ..types import _ContentRecord
|
||||
from ..shared import _bitmask
|
||||
from .util import title_ver_dec_to_standard, title_ver_standard_to_dec
|
||||
|
||||
|
||||
class TMD:
|
||||
@@ -31,9 +35,9 @@ class TMD:
|
||||
"""
|
||||
def __init__(self):
|
||||
self.blob_header: bytes = b''
|
||||
self.sig_type: int = 0
|
||||
self.sig: bytes = b''
|
||||
self.issuer: bytes = b'' # Follows the format "Root-CA%08x-CP%08x"
|
||||
self.signature_type: int = 0
|
||||
self.signature: bytes = b''
|
||||
self.signature_issuer: str = "" # Follows the format "Root-CA%08x-CP%08x"
|
||||
self.tmd_version: int = 0 # This seems to always be 0 no matter what?
|
||||
self.ca_crl_version: int = 0 # Certificate Authority Certificate Revocation List version
|
||||
self.signer_crl_version: int = 0 # Certificate Policy Certificate Revocation List version
|
||||
@@ -41,18 +45,20 @@ class TMD:
|
||||
self.ios_tid: str = "" # The Title ID of the IOS version the associated title runs on.
|
||||
self.ios_version: int = 0 # The IOS version the associated title runs on.
|
||||
self.title_id: str = "" # The Title ID of the associated title.
|
||||
self.content_type: str = "" # The type of content contained within the associated title.
|
||||
self.title_type: bytes = b'' # The type of the associated title. Should always be 00000001 in a Wii TMD.
|
||||
self.group_id: int = 0 # The ID of the publisher of the associated title.
|
||||
self.region: int = 0 # The ID of the region of the associated title.
|
||||
self.ratings: bytes = b'' # The parental controls rating of the associated title.
|
||||
self.reserved1: bytes = b'' # Unknown data labeled "Reserved" on WiiBrew.
|
||||
self.ipc_mask: bytes = b''
|
||||
self.reserved2: bytes = b'' # Other "Reserved" data from WiiBrew.
|
||||
self.access_rights: bytes = b''
|
||||
self.access_rights: int = 0
|
||||
self.title_version: int = 0 # The version of the associated title.
|
||||
self.title_version_converted: int = 0 # The title version in vX.X format.
|
||||
self.num_contents: int = 0 # The number of contents contained in the associated title.
|
||||
self.boot_index: int = 0 # The content index that contains the bootable executable.
|
||||
self.content_records: List[ContentRecord] = []
|
||||
self.minor_version: int = 0 # Minor version (unused typically).
|
||||
self.content_records: List[_ContentRecord] = []
|
||||
|
||||
def load(self, tmd: bytes) -> None:
|
||||
"""
|
||||
@@ -68,11 +74,15 @@ class TMD:
|
||||
# ====================================================================================
|
||||
# Parses each of the keys contained in the TMD.
|
||||
# ====================================================================================
|
||||
# Signature type.
|
||||
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.
|
||||
tmd_data.seek(0x140)
|
||||
self.issuer = tmd_data.read(64)
|
||||
self.signature_issuer = str(tmd_data.read(64).decode())
|
||||
# TMD version, seems to usually be 0, but I've seen references to other numbers.
|
||||
tmd_data.seek(0x180)
|
||||
self.tmd_version = int.from_bytes(tmd_data.read(1))
|
||||
@@ -97,11 +107,10 @@ class TMD:
|
||||
title_id_bin = tmd_data.read(8)
|
||||
title_id_hex = binascii.hexlify(title_id_bin)
|
||||
self.title_id = str(title_id_hex.decode())
|
||||
# Type of content.
|
||||
# Type of the title. This is an internal property used to show if this title is for the ill-fated
|
||||
# NetCard (0), or the Wii (1), and is therefore always 1 for Wii TMDs.
|
||||
tmd_data.seek(0x194)
|
||||
content_type_bin = tmd_data.read(4)
|
||||
content_type_hex = binascii.hexlify(content_type_bin)
|
||||
self.content_type = str(content_type_hex.decode())
|
||||
self.title_type = tmd_data.read(4)
|
||||
# Publisher of the title.
|
||||
tmd_data.seek(0x198)
|
||||
self.group_id = int.from_bytes(tmd_data.read(2))
|
||||
@@ -121,35 +130,36 @@ class TMD:
|
||||
# "Reserved" data 2.
|
||||
tmd_data.seek(0x1C6)
|
||||
self.reserved2 = tmd_data.read(18)
|
||||
# Access rights of the title; DVD-video access and AHBPROT.
|
||||
# Access rights of the title; DVD-video and AHB access.
|
||||
tmd_data.seek(0x1D8)
|
||||
self.access_rights = tmd_data.read(4)
|
||||
# Calculate the version number by multiplying 0x1DC by 256 and adding 0x1DD.
|
||||
self.access_rights = int.from_bytes(tmd_data.read(4))
|
||||
# Version number straight from the TMD.
|
||||
tmd_data.seek(0x1DC)
|
||||
title_version_high = int.from_bytes(tmd_data.read(1)) * 256
|
||||
tmd_data.seek(0x1DD)
|
||||
title_version_low = int.from_bytes(tmd_data.read(1))
|
||||
self.title_version = title_version_high + title_version_low
|
||||
self.title_version = int.from_bytes(tmd_data.read(2))
|
||||
# Calculate the converted version number via util module.
|
||||
self.title_version_converted = title_ver_dec_to_standard(self.title_version, self.title_id, bool(self.vwii))
|
||||
# The number of contents listed in the TMD.
|
||||
tmd_data.seek(0x1DE)
|
||||
self.num_contents = int.from_bytes(tmd_data.read(2))
|
||||
# The content index that contains the bootable executable.
|
||||
tmd_data.seek(0x1E0)
|
||||
self.boot_index = int.from_bytes(tmd_data.read(2))
|
||||
# The minor version of the title (typically unused).
|
||||
tmd_data.seek(0x1E2)
|
||||
self.minor_version = int.from_bytes(tmd_data.read(2))
|
||||
# Get content records for the number of contents in num_contents.
|
||||
self.content_records = []
|
||||
for content in range(0, self.num_contents):
|
||||
tmd_data.seek(0x1E4 + (36 * content))
|
||||
content_record_hdr = struct.unpack(">LHH4x4s20s", tmd_data.read(36))
|
||||
self.content_records.append(
|
||||
ContentRecord(int(content_record_hdr[0]), int(content_record_hdr[1]),
|
||||
int(content_record_hdr[2]), int.from_bytes(content_record_hdr[3]),
|
||||
binascii.hexlify(content_record_hdr[4])))
|
||||
_ContentRecord(int(content_record_hdr[0]), int(content_record_hdr[1]),
|
||||
int(content_record_hdr[2]), int.from_bytes(content_record_hdr[3]),
|
||||
binascii.hexlify(content_record_hdr[4])))
|
||||
|
||||
def dump(self) -> bytes:
|
||||
"""
|
||||
Dumps the TMD object back into bytes. This also sets the raw TMD attribute of TMD object to the dumped data,
|
||||
and triggers load() again to ensure that the raw data and object match.
|
||||
Dumps the TMD object back into bytes.
|
||||
|
||||
Returns
|
||||
-------
|
||||
@@ -157,10 +167,14 @@ class TMD:
|
||||
The full TMD file as bytes.
|
||||
"""
|
||||
tmd_data = b''
|
||||
# Signed blob header.
|
||||
tmd_data += self.blob_header
|
||||
# Signature type.
|
||||
tmd_data += self.signature_type
|
||||
# Signature data.
|
||||
tmd_data += self.signature
|
||||
# Padding to 64 bytes.
|
||||
tmd_data += b'\x00' * 60
|
||||
# Signing certificate issuer.
|
||||
tmd_data += self.issuer
|
||||
tmd_data += str.encode(self.signature_issuer)
|
||||
# TMD version.
|
||||
tmd_data += int.to_bytes(self.tmd_version, 1)
|
||||
# Certificate Authority CRL version.
|
||||
@@ -173,8 +187,8 @@ class TMD:
|
||||
tmd_data += binascii.unhexlify(self.ios_tid)
|
||||
# Title's Title ID.
|
||||
tmd_data += binascii.unhexlify(self.title_id)
|
||||
# Content type.
|
||||
tmd_data += binascii.unhexlify(self.content_type)
|
||||
# Title type.
|
||||
tmd_data += self.title_type
|
||||
# Group ID.
|
||||
tmd_data += int.to_bytes(self.group_id, 2)
|
||||
# 2 bytes of zero for reasons.
|
||||
@@ -190,18 +204,15 @@ class TMD:
|
||||
# "Reserved" 2.
|
||||
tmd_data += self.reserved2
|
||||
# Access rights.
|
||||
tmd_data += self.access_rights
|
||||
tmd_data += int.to_bytes(self.access_rights, 4)
|
||||
# Title version.
|
||||
title_version_high = round(self.title_version / 256)
|
||||
tmd_data += int.to_bytes(title_version_high, 1)
|
||||
title_version_low = self.title_version % 256
|
||||
tmd_data += int.to_bytes(title_version_low, 1)
|
||||
tmd_data += int.to_bytes(self.title_version, 2)
|
||||
# Number of contents.
|
||||
tmd_data += int.to_bytes(self.num_contents, 2)
|
||||
# Boot index.
|
||||
tmd_data += int.to_bytes(self.boot_index, 2)
|
||||
# Minor version. Unused so write \x00.
|
||||
tmd_data += b'\x00\x00'
|
||||
# Minor version.
|
||||
tmd_data += int.to_bytes(self.minor_version, 2)
|
||||
# Iterate over content records, write them back into raw data, then add them to the TMD.
|
||||
for content_record in range(self.num_contents):
|
||||
content_data = b''
|
||||
@@ -215,12 +226,64 @@ class TMD:
|
||||
tmd_data += content_data
|
||||
return tmd_data
|
||||
|
||||
def fakesign(self) -> None:
|
||||
"""
|
||||
Fakesigns this TMD for the trucha bug.
|
||||
|
||||
This is done by brute-forcing a TMD body hash starting with 00, causing it to pass signature verification on
|
||||
older IOS versions that incorrectly check the hash using strcmp() instead of memcmp(). The signature will also
|
||||
be erased and replaced with all NULL bytes.
|
||||
|
||||
The hash is brute-forced by incrementing an unused 16-bit integer in the TMD by 1 until an appropriate hash is
|
||||
found.
|
||||
|
||||
This modifies the TMD object in place. You will need to call this method after any changes, and before dumping
|
||||
the TMD object back into bytes.
|
||||
"""
|
||||
# Clear the signature, so that the hash derived from it is guaranteed to always be
|
||||
# '0000000000000000000000000000000000000000'.
|
||||
self.signature = b'\x00' * 256
|
||||
current_int = 0
|
||||
test_hash = ''
|
||||
while test_hash[:2] != '00':
|
||||
current_int += 1
|
||||
self.minor_version = current_int
|
||||
# Trim off the first 320 bytes, because we're only looking for the hash of the TMD's body.
|
||||
# This is a try-except because an OverflowError will be thrown if the number being used to brute-force the
|
||||
# hash gets too big, as it is only a 16-bit integer. If that happens, then fakesigning has failed.
|
||||
try:
|
||||
test_hash = hashlib.sha1(self.dump()[320:]).hexdigest()
|
||||
except OverflowError:
|
||||
raise Exception("An error occurred during fakesigning. TMD could not be fakesigned!")
|
||||
|
||||
def get_is_fakesigned(self) -> bool:
|
||||
"""
|
||||
Checks the TMD object to see if it is currently fakesigned. For a description of fakesigning, refer to the
|
||||
fakesign() method.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bool:
|
||||
True if the TMD is fakesigned, False otherwise.
|
||||
|
||||
See Also
|
||||
--------
|
||||
libWiiPy.title.tmd.TMD.fakesign()
|
||||
"""
|
||||
if self.signature != b'\x00' * 256:
|
||||
return False
|
||||
test_hash = hashlib.sha1(self.dump()[320:]).hexdigest()
|
||||
if test_hash[:2] != '00':
|
||||
return False
|
||||
return True
|
||||
|
||||
def get_title_region(self) -> str:
|
||||
"""
|
||||
Gets the region of the TMD's associated title.
|
||||
Gets the system region specified in the TMD. This is not necessarily the true region of the title, but is the
|
||||
hardware region that this title is designed and allowed to be run on.
|
||||
|
||||
Can be one of several possible values:
|
||||
'JAP', 'USA', 'EUR', 'WORLD', or 'KOR'.
|
||||
'JPN', 'USA', 'EUR', 'None', or 'KOR'.
|
||||
|
||||
Returns
|
||||
-------
|
||||
@@ -229,33 +292,19 @@ class TMD:
|
||||
"""
|
||||
match self.region:
|
||||
case 0:
|
||||
return "JAP"
|
||||
return "JPN"
|
||||
case 1:
|
||||
return "USA"
|
||||
case 2:
|
||||
return "EUR"
|
||||
case 3:
|
||||
return "WORLD"
|
||||
return "None"
|
||||
case 4:
|
||||
return "KOR"
|
||||
|
||||
def get_is_vwii_title(self) -> bool:
|
||||
"""
|
||||
Gets whether the TMD is designed for the vWii or not.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bool
|
||||
If the title is for vWii.
|
||||
"""
|
||||
if self.vwii == 1:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
def get_title_type(self) -> str:
|
||||
"""
|
||||
Gets the type of the TMD's associated title.
|
||||
Gets the type of the title this TMD describes. The title_type field is not related to these types.
|
||||
|
||||
Can be one of several possible values:
|
||||
'System', 'Game', 'Channel', 'SystemChannel', 'GameChannel', or 'HiddenChannel'
|
||||
@@ -265,8 +314,7 @@ class TMD:
|
||||
str
|
||||
The type of the title.
|
||||
"""
|
||||
title_id_high = self.title_id[:8]
|
||||
match title_id_high:
|
||||
match self.title_id[:8]:
|
||||
case '00000001':
|
||||
return "System"
|
||||
case '00010000':
|
||||
@@ -284,33 +332,45 @@ class TMD:
|
||||
case _:
|
||||
return "Unknown"
|
||||
|
||||
def get_content_type(self):
|
||||
def get_content_type(self, content_index: int) -> str:
|
||||
"""
|
||||
Gets the type of content contained in the TMD's associated title.
|
||||
|
||||
Can be one of several possible values:
|
||||
'Normal', 'Development/Unknown', 'Hash Tree', 'DLC', or 'Shared'
|
||||
|
||||
Parameters
|
||||
----------
|
||||
content_index : int
|
||||
The index of the content you want the type of.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
The type of content.
|
||||
"""
|
||||
match self.content_type:
|
||||
case '00000001':
|
||||
# Get a list of the current content indices, so we can make sure the target one exists. Doing it this way
|
||||
# ensures we can find the target, even if the highest content index is greater than the highest literal index.
|
||||
current_indices = []
|
||||
for record in self.content_records:
|
||||
current_indices.append(record.index)
|
||||
# This is the literal index in the list of content that we're going to get.
|
||||
target_index = current_indices.index(content_index)
|
||||
match self.content_records[target_index].content_type:
|
||||
case 1:
|
||||
return "Normal"
|
||||
case '00000002':
|
||||
case 2:
|
||||
return "Development/Unknown"
|
||||
case '00000003':
|
||||
case 3:
|
||||
return "Hash Tree"
|
||||
case '00004001':
|
||||
case 16385:
|
||||
return "DLC"
|
||||
case '00008001':
|
||||
case 32769:
|
||||
return "Shared"
|
||||
case _:
|
||||
return "Unknown"
|
||||
|
||||
def get_content_record(self, record) -> ContentRecord:
|
||||
def get_content_record(self, record) -> _ContentRecord:
|
||||
"""
|
||||
Gets the content record at the specified index.
|
||||
|
||||
@@ -321,7 +381,7 @@ class TMD:
|
||||
|
||||
Returns
|
||||
-------
|
||||
ContentRecord
|
||||
_ContentRecord
|
||||
A ContentRecord object containing the data in the content record.
|
||||
"""
|
||||
if record < self.num_contents:
|
||||
@@ -330,9 +390,31 @@ class TMD:
|
||||
raise IndexError("Invalid content record! TMD lists '" + str(self.num_contents - 1) +
|
||||
"' contents but index was '" + str(record) + "'!")
|
||||
|
||||
class AccessFlags(_IntEnum):
|
||||
AHB = 0
|
||||
DVD_VIDEO = 1
|
||||
|
||||
def get_access_right(self, flag: int) -> bool:
|
||||
"""
|
||||
Gets whether an access rights flag is enabled or not. This is done by checking the specified bit. Possible flags
|
||||
and their corresponding bits are defined in the AccessFlags enum.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
flag : int
|
||||
The flag to check.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bool
|
||||
True if the flag is enabled, False otherwise.
|
||||
"""
|
||||
return bool(self.access_rights & _bitmask(flag))
|
||||
|
||||
def set_title_id(self, title_id) -> None:
|
||||
"""
|
||||
Sets the Title ID of the title in the ticket.
|
||||
Sets the Title ID property of the TMD. Recommended over setting the property directly because of input
|
||||
validation.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
@@ -342,3 +424,37 @@ class TMD:
|
||||
if len(title_id) != 16:
|
||||
raise ValueError("Invalid Title ID! Title IDs must be 8 bytes long.")
|
||||
self.title_id = title_id
|
||||
|
||||
def set_title_version(self, new_version: str | int) -> None:
|
||||
"""
|
||||
Sets the version of the title in the TMD. Recommended over setting the data directly because of input
|
||||
validation.
|
||||
|
||||
Accepts either standard form (vX.X) as a string or decimal form (vXXX) as an integer.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
new_version : str, int
|
||||
The new version of the title. See description for valid formats.
|
||||
"""
|
||||
if type(new_version) is str:
|
||||
# Validate string input is in the correct format, then validate that the version isn't higher than v255.0.
|
||||
# If checks pass, set that as the converted version, then convert to decimal form and set that as well.
|
||||
version_str_split = new_version.split(".")
|
||||
if len(version_str_split) != 2:
|
||||
raise ValueError("Title version is not valid! String version must be entered in format \"X.X\".")
|
||||
if int(version_str_split[0]) > 255 or int(version_str_split[1]) > 255:
|
||||
raise ValueError("Title version is not valid! String version number cannot exceed v255.255.")
|
||||
self.title_version_converted = new_version
|
||||
version_converted = title_ver_standard_to_dec(new_version, self.title_id)
|
||||
self.title_version = version_converted
|
||||
elif type(new_version) is int:
|
||||
# Validate that the version isn't higher than v65280. If the check passes, set that as the title version,
|
||||
# then convert to standard form and set that as well.
|
||||
if new_version > 65535:
|
||||
raise ValueError("Title version is not valid! Integer version number cannot exceed v65535.")
|
||||
self.title_version = new_version
|
||||
version_converted = title_ver_dec_to_standard(new_version, self.title_id, bool(self.vwii))
|
||||
self.title_version_converted = version_converted
|
||||
else:
|
||||
raise TypeError("Title version type is not valid! Type must be either integer or string.")
|
||||
|
||||
80
src/libWiiPy/title/util.py
Normal file
80
src/libWiiPy/title/util.py
Normal file
@@ -0,0 +1,80 @@
|
||||
# "title/util.py" from libWiiPy by NinjaCheetah & Contributors
|
||||
# https://github.com/NinjaCheetah/libWiiPy
|
||||
#
|
||||
# General title-related utilities that don't fit within a specific module.
|
||||
|
||||
import math
|
||||
from ..shared import _wii_menu_versions, _vwii_menu_versions
|
||||
|
||||
|
||||
def title_ver_dec_to_standard(version: int, title_id: str, vwii: bool = False) -> str:
|
||||
"""
|
||||
Converts a title's version from decimal form (vXXX, the way the version is stored in the TMD/Ticket) to its standard
|
||||
and human-readable form (vX.X). The Title ID is required as some titles handle this version differently from others.
|
||||
For the System Menu, the returned version will include the region code (ex. 4.3U).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
version : int
|
||||
The version of the title, in decimal form.
|
||||
title_id : str
|
||||
The Title ID that the version is associated with.
|
||||
vwii : bool
|
||||
Whether this title is for the vWii or not. Only relevant for the System Menu.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
The version of the title, in standard form.
|
||||
"""
|
||||
version_out = ""
|
||||
if title_id == "0000000100000002":
|
||||
if vwii:
|
||||
try:
|
||||
version_out = list(_vwii_menu_versions.keys())[list(_vwii_menu_versions.values()).index(version)]
|
||||
except ValueError:
|
||||
version_out = ""
|
||||
else:
|
||||
try:
|
||||
version_out = list(_wii_menu_versions.keys())[list(_wii_menu_versions.values()).index(version)]
|
||||
except ValueError:
|
||||
version_out = ""
|
||||
else:
|
||||
# For most channels, we need to get the floored value of version / 256 for the major version, and the version %
|
||||
# 256 as the minor version. Minor versions > 9 are intended, as Nintendo themselves frequently used them.
|
||||
version_upper = math.floor(version / 256)
|
||||
version_lower = version % 256
|
||||
version_out = f"{version_upper}.{version_lower}"
|
||||
|
||||
return version_out
|
||||
|
||||
|
||||
def title_ver_standard_to_dec(version: str, title_id: str) -> int:
|
||||
"""
|
||||
Converts a title's version from its standard and human-readable form (vX.X) to its decimal form (vXXX, the way the
|
||||
version is stored in the TMD/Ticket). The Title ID is required as some titles handle this version differently from
|
||||
others. For the System Menu, the supplied version must include the region code (ex. 4.3U) for the conversion to
|
||||
work correctly.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
version : str
|
||||
The version of the title, in standard form.
|
||||
title_id : str
|
||||
The Title ID that the version is associated with.
|
||||
|
||||
Returns
|
||||
-------
|
||||
int
|
||||
The version of the title, in decimal form.
|
||||
"""
|
||||
version_out = 0
|
||||
if title_id == "0000000100000002":
|
||||
raise ValueError("The System Menu's version cannot currently be converted.")
|
||||
else:
|
||||
version_str_split = version.split(".")
|
||||
version_upper = int(version_str_split[0]) * 256
|
||||
version_lower = int(version_str_split[1])
|
||||
version_out = version_upper + version_lower
|
||||
|
||||
return version_out
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import io
|
||||
import binascii
|
||||
from ..shared import align_value, pad_bytes
|
||||
from ..shared import _align_value, _pad_bytes
|
||||
|
||||
|
||||
class WAD:
|
||||
@@ -91,9 +91,11 @@ class WAD:
|
||||
# WAD TMD size.
|
||||
wad_data.seek(0x14)
|
||||
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)
|
||||
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.
|
||||
wad_data.seek(0x1c)
|
||||
self.wad_meta_size = int(binascii.hexlify(wad_data.read(4)), 16)
|
||||
@@ -101,13 +103,13 @@ class WAD:
|
||||
# Calculate file offsets from sizes. Every section of the WAD is padded out to a multiple of 0x40.
|
||||
# ====================================================================================
|
||||
wad_cert_offset = self.wad_hdr_size
|
||||
# crl isn't ever used, however an entry for its size exists in the header, so its calculated just in case.
|
||||
wad_crl_offset = align_value(wad_cert_offset + self.wad_cert_size)
|
||||
wad_tik_offset = align_value(wad_crl_offset + self.wad_crl_size)
|
||||
wad_tmd_offset = align_value(wad_tik_offset + self.wad_tik_size)
|
||||
# crl isn't ever used, however an entry for its size exists in the header, so it's calculated just in case.
|
||||
wad_crl_offset = _align_value(wad_cert_offset + self.wad_cert_size)
|
||||
wad_tik_offset = _align_value(wad_crl_offset + self.wad_crl_size)
|
||||
wad_tmd_offset = _align_value(wad_tik_offset + self.wad_tik_size)
|
||||
wad_content_offset = _align_value(wad_tmd_offset + self.wad_tmd_size)
|
||||
# meta isn't guaranteed to be used, but some older SDK titles use it, and not reading it breaks things.
|
||||
wad_meta_offset = align_value(wad_tmd_offset + self.wad_tmd_size)
|
||||
wad_content_offset = align_value(wad_meta_offset + self.wad_meta_size)
|
||||
wad_meta_offset = _align_value(wad_content_offset + self.wad_content_size)
|
||||
# ====================================================================================
|
||||
# Load data for each WAD section based on the previously calculated offsets.
|
||||
# ====================================================================================
|
||||
@@ -159,25 +161,25 @@ class WAD:
|
||||
wad_data += int.to_bytes(self.wad_content_size, 4)
|
||||
# WAD meta size.
|
||||
wad_data += int.to_bytes(self.wad_meta_size, 4)
|
||||
wad_data = pad_bytes(wad_data)
|
||||
wad_data = _pad_bytes(wad_data)
|
||||
# Retrieve the cert data and write it out.
|
||||
wad_data += self.get_cert_data()
|
||||
wad_data = pad_bytes(wad_data)
|
||||
wad_data = _pad_bytes(wad_data)
|
||||
# Retrieve the crl data and write it out.
|
||||
wad_data += self.get_crl_data()
|
||||
wad_data = pad_bytes(wad_data)
|
||||
wad_data = _pad_bytes(wad_data)
|
||||
# Retrieve the ticket data and write it out.
|
||||
wad_data += self.get_ticket_data()
|
||||
wad_data = pad_bytes(wad_data)
|
||||
wad_data = _pad_bytes(wad_data)
|
||||
# Retrieve the TMD data and write it out.
|
||||
wad_data += self.get_tmd_data()
|
||||
wad_data = pad_bytes(wad_data)
|
||||
# Retrieve the meta/footer data and write it out.
|
||||
wad_data += self.get_meta_data()
|
||||
wad_data = pad_bytes(wad_data)
|
||||
wad_data = _pad_bytes(wad_data)
|
||||
# Retrieve the content data and write it out.
|
||||
wad_data += self.get_content_data()
|
||||
wad_data = pad_bytes(wad_data)
|
||||
wad_data = _pad_bytes(wad_data)
|
||||
# Retrieve the meta/footer data and write it out.
|
||||
wad_data += self.get_meta_data()
|
||||
wad_data = _pad_bytes(wad_data)
|
||||
return wad_data
|
||||
|
||||
def get_wad_type(self) -> str:
|
||||
@@ -309,7 +311,7 @@ class WAD:
|
||||
# Calculate the size of the new Ticket 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.
|
||||
|
||||
@@ -317,10 +319,15 @@ class WAD:
|
||||
----------
|
||||
content_data : bytes
|
||||
The new content data.
|
||||
size : int, option
|
||||
The size of the new content data.
|
||||
"""
|
||||
self.wad_content_data = content_data
|
||||
# Calculate the size of the new content data.
|
||||
self.wad_content_size = len(content_data)
|
||||
# Calculate the size of the new content data, if one wasn't supplied.
|
||||
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:
|
||||
"""
|
||||
|
||||
@@ -4,7 +4,7 @@ from dataclasses import 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
|
||||
the content stored at the index in the record, or else the content will not decrypt properly, as the hash of the
|
||||
@@ -28,24 +28,3 @@ class ContentRecord:
|
||||
content_type: int # Type of content, possible values of: 0x0001: Normal, 0x4001: DLC, 0x8001: Shared.
|
||||
content_size: int
|
||||
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
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
|
||||
import unittest
|
||||
|
||||
from test_commonkeys import TestCommonKeys
|
||||
from .title.commonkeys_test import *
|
||||
from .title.nus_test import *
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
0
test/title/__init__.py
Normal file
0
test/title/__init__.py
Normal file
21
test/title/commonkeys_test.py
Normal file
21
test/title/commonkeys_test.py
Normal file
@@ -0,0 +1,21 @@
|
||||
# "commonkeys_test.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()
|
||||
67
test/title/nus_test.py
Normal file
67
test/title/nus_test.py
Normal file
@@ -0,0 +1,67 @@
|
||||
# "nus_test.py" from libWiiPy by NinjaCheetah & Contributors
|
||||
# https://github.com/NinjaCheetah/libWiiPy
|
||||
|
||||
import hashlib
|
||||
import unittest
|
||||
|
||||
import libWiiPy
|
||||
|
||||
|
||||
class TestNUSDownloads(unittest.TestCase):
|
||||
def test_download_title(self):
|
||||
title = libWiiPy.title.download_title("0000000100000002", 513)
|
||||
title_hash = hashlib.sha1(title.dump_wad()).hexdigest()
|
||||
self.assertEqual(title_hash, "c5e25fdb1ae6921597058b9f07045be0b003c550")
|
||||
title = libWiiPy.title.download_title("0000000100000002", 513, wiiu_endpoint=True)
|
||||
title_hash = hashlib.sha1(title.dump_wad()).hexdigest()
|
||||
self.assertEqual(title_hash, "c5e25fdb1ae6921597058b9f07045be0b003c550")
|
||||
|
||||
def test_download_tmd(self):
|
||||
tmd = libWiiPy.title.download_tmd("0000000100000002", 513)
|
||||
tmd_hash = hashlib.sha1(tmd).hexdigest()
|
||||
self.assertEqual(tmd_hash, "e8f9657d591b305e300c109b5641630aa4e2318b")
|
||||
tmd = libWiiPy.title.download_tmd("0000000100000002", 513, wiiu_endpoint=True)
|
||||
tmd_hash = hashlib.sha1(tmd).hexdigest()
|
||||
self.assertEqual(tmd_hash, "e8f9657d591b305e300c109b5641630aa4e2318b")
|
||||
with self.assertRaises(ValueError):
|
||||
libWiiPy.title.download_tmd("TEST_STRING")
|
||||
|
||||
def test_download_ticket(self):
|
||||
ticket = libWiiPy.title.download_ticket("0000000100000002")
|
||||
ticket_hash = hashlib.sha1(ticket).hexdigest()
|
||||
self.assertEqual(ticket_hash, "7076891f96ad3e4a6148a4a308e4a12fc72cc4b5")
|
||||
ticket = libWiiPy.title.download_ticket("0000000100000002", wiiu_endpoint=True)
|
||||
ticket_hash = hashlib.sha1(ticket).hexdigest()
|
||||
self.assertEqual(ticket_hash, "7076891f96ad3e4a6148a4a308e4a12fc72cc4b5")
|
||||
with self.assertRaises(ValueError):
|
||||
libWiiPy.title.download_ticket("TEST_STRING")
|
||||
|
||||
def test_download_cert(self):
|
||||
cert = libWiiPy.title.download_cert()
|
||||
self.assertIsNotNone(cert)
|
||||
cert = libWiiPy.title.download_cert(wiiu_endpoint=True)
|
||||
self.assertIsNotNone(cert)
|
||||
|
||||
def test_download_content(self):
|
||||
content = libWiiPy.title.download_content("0000000100000002", 150)
|
||||
content_hash = hashlib.sha1(content).hexdigest()
|
||||
self.assertEqual(content_hash, "1f10abe6517d29950aa04c71b264c18d204ed363")
|
||||
content = libWiiPy.title.download_content("0000000100000002", 150, wiiu_endpoint=True)
|
||||
content_hash = hashlib.sha1(content).hexdigest()
|
||||
self.assertEqual(content_hash, "1f10abe6517d29950aa04c71b264c18d204ed363")
|
||||
with self.assertRaises(ValueError):
|
||||
libWiiPy.title.download_content("TEST_STRING", 150)
|
||||
with self.assertRaises(ValueError):
|
||||
libWiiPy.title.download_content("0000000100000002", -1)
|
||||
|
||||
def test_download_contents(self):
|
||||
tmd = libWiiPy.title.TMD()
|
||||
tmd.load(libWiiPy.title.download_tmd("0000000100000002"))
|
||||
contents = libWiiPy.title.download_contents("0000000100000002", tmd)
|
||||
self.assertIsNotNone(contents)
|
||||
contents = libWiiPy.title.download_contents("0000000100000002", tmd, wiiu_endpoint=True)
|
||||
self.assertIsNotNone(contents)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -1,21 +0,0 @@
|
||||
# "test_commonkeys.py" from libWiiPy by NinjaCheetah & Contributors
|
||||
# https://github.com/NinjaCheetah/libWiiPy
|
||||
|
||||
import unittest
|
||||
|
||||
from libWiiPy import commonkeys
|
||||
|
||||
|
||||
class TestCommonKeys(unittest.TestCase):
|
||||
def test_common(self):
|
||||
self.assertEqual(commonkeys.get_common_key(0), b'\xeb\xe4*"^\x85\x93\xe4H\xd9\xc5Es\x81\xaa\xf7')
|
||||
|
||||
def test_korean(self):
|
||||
self.assertEqual(commonkeys.get_common_key(1), b'c\xb8+\xb4\xf4aN.\x13\xf2\xfe\xfb\xbaL\x9b~')
|
||||
|
||||
def test_vwii(self):
|
||||
self.assertEqual(commonkeys.get_common_key(2), b'0\xbf\xc7n|\x19\xaf\xbb#\x1630\xce\xd7\xc2\x8d')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user