Compare commits

..

132 Commits
v0.2.2 ... main

Author SHA1 Message Date
e06bb39f4c
Properly create/update uid.sys during EmuNAND title installs 2025-04-18 13:54:28 -04:00
8269a0db98
(Title) Add check to make sure Ticket data exist before decrypting content 2025-04-17 16:31:16 -04:00
8adbef26b1
Updated README to reflect features since v0.5.0 2025-02-10 16:26:45 -05:00
5dde9f7835
Changed how the Title ID is handled in Tickets, stubbed out NUS endpoint validation 2025-02-10 13:36:39 -05:00
93abad1f31
LZ77 Compression: Now 12.5% faster! 2025-01-29 22:48:58 -05:00
9eabf2caee
Added a lower LZ77 compression level that runs faster 2025-01-26 12:56:41 -05:00
5ae867197b
(doc) Workaround for ScrollSpy issue breaking TOC 2025-01-25 20:40:44 -05:00
6552dc5fa8
Add LZ77 Compression Function (#22)
* Unfinished wiiload module and LZ77 compression code

* Updated WIP LZ77 compressor, still not working yet

* Updated WIP LZ77 compressor, still not working yet (again)

* LZ77 compression is now fully functional! (But still very slow)

* Added compress_lz77 docstrings, temporarily removed unfinished wiiload module
2025-01-23 22:26:34 -05:00
93790d6f58
Added docs for LZ77 module 2025-01-16 22:43:51 -05:00
f0b79e1f39
Match Root-CA00000002-XS00000004 as a dev Ticket 2025-01-12 22:23:32 -05:00
06b36290ed
Add error handling for custom NUS endpoints 2025-01-12 21:54:16 -05:00
47472e7b94
Added LZ77 decompression module 2025-01-08 18:43:48 -05:00
7c5af6ebe0
Always user all uppercase when getting installed titles on EmuNAND 2025-01-02 17:20:09 -05:00
046645eb56
Added method to title module to get if a title is signed legitimately 2024-12-23 23:50:14 -05:00
e45c7a3076
(docs) Began writing module descriptions 2024-12-21 18:09:37 -05:00
c2f6225500
Entirely restructured API documentation, now much easier to navigate 2024-12-20 19:21:53 -05:00
04d17a58d2
Added missing note about retail vs development root keys 2024-12-16 22:24:27 -05:00
aa9e8fb4ea
Updated placeholder docstrings in cert module 2024-12-16 22:22:11 -05:00
8a15b1e82e
Handle cert chain data in a title as a CertificateChain instead of bytes 2024-12-16 22:14:07 -05:00
ece19177c4
Added error handling to cert module, added support for dev CA cert 2024-12-16 21:55:58 -05:00
3a44eaf2cf
Added new CertificateChain class to manage the certs in a chain
Some checks failed
Build and publish documentation with Sphinx / build (push) Has been cancelled
Build and publish documentation with Sphinx / deploy (push) Has been cancelled
2024-12-15 23:09:13 -05:00
2fdd808137
Added new cert module to parse certs and functions to use them for verification 2024-12-15 22:08:51 -05:00
f98a3703a4
Fixed EmuNAND title installs when the title wasn't loaded from a WAD 2024-12-12 22:29:33 -05:00
1e6952c2b2
Fixed invalid default value for nus.download_title() 2024-12-12 21:54:00 -05:00
944fb896b5
Exclude DLC size from size total in tmd module 2024-12-12 20:05:43 -05:00
3d4d3dc99e
Use .lower() since title paths are lowercase 2024-12-12 17:32:44 -05:00
62f99165c7
Split methods to get the TMD and Ticket in EmuNAND module 2024-12-12 17:25:23 -05:00
e227f4e2be
Added methods to get content size to TMD module 2024-12-12 11:47:15 -05:00
da16259938
Added method to EmuNAND module to get the TMD and Ticket for an installed title 2024-12-11 21:37:56 -05:00
1cce0f14ee
Allow specifying a custom endpoint URL for NUS downloads 2024-12-08 22:06:32 -05:00
c86b44f35c
Restructured IMET header code, now in archive/u8 and not media/banner 2024-11-30 18:54:50 -05:00
1ff4ecdf68
Added method to query all titles installed in EmuNAND 2024-11-28 00:49:54 -05:00
302bd842d1
Remove leftover import now that banner.py has been moved 2024-11-21 19:20:32 -05:00
c5a007e1f5
Small fix for docs 2024-11-21 19:18:03 -05:00
e96f6d9f13
Finished IMETHeader class, can now load, dump, create, and get/set channel names 2024-11-21 19:08:52 -05:00
57b2ed63d4
Disable TMD/Ticket TID match check 2024-11-21 16:22:12 -05:00
855200bb98
Add preliminary support for parsing 00000000.app
New module banner.py offers classes for IMD5 and IMET headers, U8 unpacker now supports U8 archives with IMET headers.
2024-11-18 17:00:18 -05:00
cfd105ba81
Updated banner used in README 2024-11-10 23:22:24 -05:00
ed7e928ad8
Updated banner 2024-11-10 23:20:30 -05:00
6b18254edc
Small README change to reflect dev support 2024-10-20 22:41:34 -04:00
1b6e0db26d
Revert changes related to processing content indices
Changes released in libWiiPy v0.5.0 and v0.5.1 to how indices were handled ended up way overcomplicating things, resulting in lots of issues now that I'm working with the content module again in WiiPy. These changes have mostly been reverted.
The issues were related to handling WADs where the content indices don't align with the actual index of the content, like in cases where content has bene removed. This issue has been fixed again with a new and much simpler patch that should not introduce new bugs.
2024-10-20 19:03:26 -04:00
9ae059b797
Add support for extracting/packing/otherwise handling dev WADs 2024-10-13 21:39:52 -04:00
c604c195d2
Correct line endings when dumping setting.txt 2024-10-09 20:38:00 -04:00
0c2e13f18a
Remove leftover debugging print 2024-10-08 14:06:09 -04:00
7fed039fdc
Added methods to get a content index from a CID and add content from a Title() 2024-09-13 14:56:37 -04:00
0d306076a2
Added methods to content module to remove contents by index or CID 2024-09-11 11:13:01 -04:00
a1773b9a02
Improved adding new content to title, fixed minor bugs
Dumping a title now properly updates the "number of contents" field in the TMD, so you're able to add more content than there was previously, and that new content will be added correctly.
2024-09-08 13:15:52 -04:00
7c2f0fb21f
Fix getting a title's type 2024-09-05 11:07:14 -04:00
0edd4fa6bb
Update how TMD regions are handled 2024-09-04 14:28:13 -04:00
e163d34f0b
Allow for calculating title size with and without shared content 2024-09-04 14:23:24 -04:00
9fb0fdbc17
Added setting.txt parser, moved some modules under a new "nand" subpackage 2024-08-14 01:26:46 -04:00
1ae649afac
Update README in preparation for v0.5.0 release 2024-08-12 15:26:03 -04:00
b782e5dea5
Fix typing issue with emunand class 2024-08-08 13:39:22 -04:00
894aa3a04b
Added new module for EmuNAND features previously found in WiiPy 2024-08-08 13:24:10 -04:00
152a13fbe2
Rewrote most of U8 module, now extracts and packs all archives as expected 2024-08-07 22:46:28 -04:00
72a8b9b6a6
Added automatic documentation for modules new since v0.4.1 2024-08-04 01:04:47 -04:00
128f4a9303
Merge remote-tracking branch 'origin/main' 2024-08-03 23:26:50 -04:00
bab777b8b9
Added new module for handling sys files, currently supports uid.sys 2024-08-03 23:26:32 -04:00
fb87c2c58c
Added methods to check if a TMD, Ticket, and Title are fakesigned 2024-08-03 14:01:09 -04:00
6220821a2f
Cleaned up AccessFlags enum in TMD class 2024-08-03 13:44:15 -04:00
580ba8526f
Add method to check for specific access rights in TMD 2024-08-03 13:36:35 -04:00
7e308a35eb
Save access rights as an int and not bytes 2024-08-03 13:13:29 -04:00
194b65c6d6
Added hard-coded table of System Menu versions for conversions 2024-08-03 02:12:47 -04:00
cfd5abac7e
Merge remote-tracking branch 'origin/main' 2024-08-02 23:57:58 -04:00
7edf764768
Fix handling of title and content types in tmd module 2024-08-02 23:57:43 -04:00
544e65a109 Fix title version cap 2024-08-02 14:28:22 -04:00
bcbdd284e9
Don't automatically set content to normal when applying IOS patches 2024-08-02 08:40:39 -04:00
415af7b8b8
Write the footer at the bottom when dumping 2024-08-01 17:04:59 -04:00
f81398e854
Disable automatically making patched content non-shared 2024-08-01 15:49:06 -04:00
60975dc62d
Add experimental patch to make IOS work without a DVD drive 2024-07-31 01:29:54 -04:00
40e4459893
Added some basic tests for the commonkeys and nus modules 2024-07-29 19:28:28 -04:00
5c56eabe9f
Fix a minor bug in how title versions were handled in the ticket module 2024-07-29 16:46:38 -04:00
9d26ff74ff
Return count of applied patches from iospatcher instead of erroring on 0 applied 2024-07-29 15:29:51 -04:00
18b54af091
Fix formatting issue with setting the Title ID in the ticket module 2024-07-28 03:33:28 -04:00
2d67f982dc
Re-encrypt Title Key when setting the Title ID (title module only) 2024-07-28 03:28:44 -04:00
d6e6352d0a
Correctly generate IV for Title Key decrpytion for all Title ID formats 2024-07-28 03:15:51 -04:00
7daba7ec86
Add IOSPatcher to apply patches to IOS WADs loaded into a Title()
Also fixes a MAJOR bug with WAD packing where changes to the content records would be dropped when dumping a WAD (!!)
2024-07-28 01:43:46 -04:00
930e09828e
Always return lowercase file names from SharedContentMap.add_content() 2024-07-27 20:47:22 -04:00
a5ce7e9cd1
Minor fix for accepting different input types for SharedContentMap.add_content() 2024-07-27 19:56:43 -04:00
76b773ee36
Fix adding content to the content map if it is empty 2024-07-27 19:36:27 -04:00
817a2c9ac5
Added SharedContentMap to content module to handle content.map files 2024-07-27 19:22:21 -04:00
102da808e6
Add option to skip hash checks when unpacking a WAD 2024-07-25 21:14:12 -04:00
f7f67d3414
Rewrote most of the content module, code is much cleaner now
It also has more checks, so that should ensure that more errors get caught and aren't either ignored or allowed to fall through to the interpreter.
Also, part of this cleanup is that the content module now entirely operates on content indexes and not literal indexes, so this makes sure that WADs where the content and literal indexes don't match are handed properly
2024-07-25 17:15:26 -04:00
39eecec864
Fall back on key 0 when invalid, fix footer reading code 2024-07-25 13:09:01 -04:00
5f4fa8827c
Added new methods to TMD/Ticket/Title modules for changing title versions 2024-07-22 02:42:04 -04:00
e70b9570de
Fix handling WADs where content index != actual index in the array 2024-07-20 15:07:23 -04:00
4f96e1b0d9
Add more detailed keys to pyproject.toml 2024-07-17 21:03:08 -04:00
bcd61b8a37
Slightly improve fakesign docstrings 2024-07-17 20:48:16 -04:00
a56fa6e051
Added methods to fakesign a TMD or Ticket 2024-07-17 20:44:04 -04:00
535de7f228
Read/write minor version in tmd module, allows for fakesigning 2024-07-10 20:18:15 +10:00
adac67b158
Change title version handling in tmd module
Now saving the version number (like v513) straight from the TMD and using that to dump the TMD, in case the converted version number (like v2.2) doesn't work right, which mostly applies to the system menu.
2024-07-10 08:11:14 +10:00
f96da98d23
Separate out components of a TMD signature
Not sure why this hadn't been done already given that the ticket module has always used separate properties for the signature type and data.
2024-07-06 20:15:18 +10:00
c5abf16f67
Finally fix content region size in WAD headers 2024-07-06 20:00:18 +10:00
45638769a9
Allow for extracting a U8 archive to an existing (empty) directory 2024-07-05 08:34:23 +10:00
1f731bbc81
Improve WAD handling, fixes IOS WADs made with other tools not extracting
The way content sizes are handled has been adjusted to allow IOS WADs (which have their content structured a bit differently) made via other tools to be extracted.
Writing out WADs has also been changed so that the content size in the header now matches the output of older tools.
2024-07-02 18:29:15 +10:00
9bfb44771e
Merge remote-tracking branch 'origin/main' 2024-06-26 17:49:24 -04:00
82f4e7dcc4
Wrote new guide on extracting data from a WAD 2024-06-26 17:49:10 -04:00
c4847365ab
Replaced README banner at the Python Software Foundation's request
libWiiPy's previous banner used the Python logo in a manner deemed unacceptable by the PSF, and they have requested that its use be discontinued immediately.
2024-06-26 11:10:09 -04:00
e00fc376c6
Replaced banner at the Python Software Foundation's request 2024-06-26 11:08:19 -04:00
04fa9034a0
Added new plugins, began writing tutorials 2024-06-26 01:15:43 -04:00
df1ed559ef
Rewrote all docs in Markdown instead of the default reST 2024-06-25 22:57:09 -04:00
53e098ca9b
Make sure libWiiPy is built and installed so Sphinx can import it 2024-06-25 21:38:33 -04:00
da2d6b0003
Added workflow to build and publish docs on push 2024-06-25 17:42:28 -04:00
6575dd37f7
Added basics for a proper API docs website 2024-06-25 17:34:34 -04:00
75510ed2b9
Updated README to reflect changes being worked on for v0.4.0 2024-06-24 23:19:16 -04:00
53f798e36a
Merge pull request #20 from NinjaCheetah/ash-dec
Add support for decompressing ASH files
2024-06-23 18:37:06 -04:00
0861c20100
Privatized many functions and classes that should be private
Also changed some imports to import as _name so that stuff like dataclass() doesn't appear as available under libWiiPy.title
2024-06-23 18:28:32 -04:00
2d64f7961e
Added new ash module to archive subpackage, can decompress ASH files 2024-06-23 17:59:12 -04:00
7b7a3fbf51
Return FileNotFoundError if path doesn't exist in pack_u8() 2024-06-12 14:11:57 -04:00
6b8eda7823
Also automatically import crypto for the title subpackage 2024-06-07 23:36:20 -04:00
af6977a23c
Fix imports for new package structure 2024-06-07 23:30:02 -04:00
96c975728b
Merge branch 'refs/heads/u8' 2024-06-07 23:22:08 -04:00
2fbb2b04f3
Fix nus module not being imported automatically 2024-06-07 23:21:03 -04:00
195a005752
Merge pull request #19 from NinjaCheetah/u8
Add U8 packing and extracting
2024-06-07 23:12:50 -04:00
736a9e5c0c
Update README for v0.3.0 release 2024-06-07 23:11:51 -04:00
5743ee2695
Remove some pointless comments I noticed 2024-06-07 23:05:45 -04:00
b30017460b
Completed the U8 module, can now handle full folder packing 2024-06-07 22:30:34 -04:00
2755364472
Rewrote U8 extraction code entirely, now handles U8 files in all cases 2024-06-05 22:02:19 -04:00
1d77868cb1
Reworked U8 module and added initial support for dumping U8 archives back to bytes 2024-06-03 23:20:34 -04:00
ade4b68394
Entirely restructured package to make it more of a proper Python package
THIS IS A BREAKING COMMIT! ALL v0.2.X BASED PROJECTS WILL NEED TO BE UPDATED TO SUPPORT v0.3.X!
2024-06-02 22:18:05 -04:00
bc9224e40b
Added base for tests 2024-05-29 20:05:41 -04:00
cbaafca0d1
Working basic code to extract a U8 archive 2024-05-29 12:48:44 -04:00
ede33dc503
Add highly experimental U8 handling module 2024-05-16 21:24:42 -04:00
6a81722ec5
Read/write reserved data in tmd.py, since it mattered for the DSi, it may matter here 2024-05-09 11:19:29 -04:00
ecc68d9e57
Updated definitions of TMD properties based on new information 2024-05-09 11:11:54 -04:00
c42dc66209
Update README.md 2024-05-07 21:35:22 -04:00
045613216a
Replaced unnecessary BytesIO usages with standard variables 2024-05-06 19:34:18 -04:00
98666285db Improved comments, moved TID to IV conversion into a function in shared.py 2024-05-03 22:32:51 -04:00
ba320a29de Improved IV handling code for Title Keys in crypto.py 2024-05-03 17:27:52 -04:00
9890a6dbac Added function to crypto.py for encrypting a title key 2024-05-03 15:10:17 -04:00
c92a8096ea
Rename fallback_endpoint to wiiu_endpoint in nus.py 2024-05-02 22:20:59 -04:00
99a55a3de5
Added the ability to use the Wii U NUS servers to nus.py 2024-05-01 22:22:30 -04:00
75 changed files with 5884 additions and 1554 deletions

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

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

4
.gitignore vendored
View File

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

View File

@ -1,20 +1,32 @@
![libWiiPy](https://github.com/NinjaCheetah/libWiiPy/assets/58050615/80093c68-b86e-4b96-87b7-db3855382ca8)
![banner](https://github.com/user-attachments/assets/eb30a500-6d27-42f1-bded-24221930a8e3)
# libWiiPy
libWiiPy is a modern Python 3 library for interacting with and editing files from the Wii. It aims to be simple to use, well maintained, and offer as many features as reasonably possible in one library, so that a newly-written Python program could reasonably do 100% of its Wii-related work with just one library. It also aims to be fully cross-platform, so that any tools written with it can also be cross-platform.
libWiiPy is a modern Python 3 library for handling the various files and formats found on the Wii. It aims to be simple to use, well maintained, and offer as many features as reasonably possible in one library, so that a newly-written Python program could do 100% of its Wii-related work with just one library. It also aims to be fully cross-platform, so that any tools written with it can also be cross-platform.
libWiiPy is inspired by [libWiiSharp](https://github.com/TheShadowEevee/libWiiSharp), originally created by `Leathl`, now maintained by [@TheShadowEevee](https://github.com/TheShadowEevee). libWiiSharp is absolutely the way to go if you need a C# library for Wii files.
libWiiPy is inspired by [libWiiSharp](https://github.com/TheShadowEevee/libWiiSharp), which was originally created by `Leathl` and is now maintained by [@TheShadowEevee](https://github.com/TheShadowEevee).
**Note:** While libWiiPy is directly inspired by libWiiSharp and aims to have feature parity with it, no code from either libWiiSharp or Wii.py was used in the making of this library. All code is original and is written by [@NinjaCheetah](https://github.com/NinjaCheetah), [@rvtr](https://github.com/rvtr), and any other GitHub contributors.
# Features
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
- Downloading free titles from the NUS
- TMD and Ticket parsing/editing (`.tmd`, `.tik`)
- Title parsing/editing, including content encryption/decryption (both retail and development)
- WAD file parsing/editing (`.wad`)
- Downloading titles and their components from the NUS
- Certificate, TMD, and Ticket signature verification
- Packing and unpacking U8 archives (`.app`, `.arc`)
- Decompressing ASH files (`.ash`, both the standard variants and the variants found in My Pokémon Ranch)
- Compressing/Decompressing LZ77-compressed files
- IOS patching
- NAND-related functionality:
- EmuNAND title management (currently requires an existing EmuNAND)
- `content.map` parsing/editing
- `setting.txt` parsing/editing
- `uid.sys` parsing/editing
- Limited channel banner 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
@ -26,36 +38,37 @@ Because libWiiPy is very early in development, you may want to use the latest ve
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!
First, install the dependencies from `requirements.txt`:
```py
```sh
pip install -r requirements.txt
```
Then, build the package using the Python `build` module:
```py
```sh
python -m build
```
And that's all! You'll find your compiled pip package in `dist/`.
# Special Thanks
This project wouldn't be possible without the amazing people behind its predecessors and all of the people who have contributed to the documentation of the Wii's inner workings over at [Wiibrew](https://wiibrew.org).
This project wouldn't be possible without the amazing people behind its predecessors and all of the people who have contributed to the documentation of the Wii's inner workings over at [WiiBrew](https://wiibrew.org).
## Special Thanks for the Inspiration and Previous Projects
## Special Thanks to People Behind Related Projects
- Xuzz, SquidMan, megazig, Matt_P, Omega and The Lemon Man for creating Wii.py
- Leathl for creating libWiiSharp
- TheShadowEevee for maintaining libWiiSharp
## Special Thanks to Wiibrew Contributors
Thank you to all of the contributors to the documentation on the Wiibrew pages that make this all understandable! Some of the key articles referenced are as follows:
## Special Thanks to WiiBrew Contributors
Thank you to all of the contributors to the documentation on the WiiBrew pages that make this all understandable! Some of the key articles referenced are as follows:
- [Title metadata](https://wiibrew.org/wiki/Title_metadata), for the documentation on how a TMD is structured
- [WAD files](https://wiibrew.org/wiki/WAD_files), for the documentation on how a WAD is structured
- [IOS history](https://wiibrew.org/wiki/IOS_history), for the documentation on IOS TIDs and how IOS is versioned
### One additional special thanks to [@DamiDoop](https://github.com/DamiDoop)!
She made the very cool banner you can see at the top of this README, and has also helped greatly with my sanity throughout debugging this library.

20
docs/Makefile Normal file
View File

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

35
docs/make.bat Normal file
View File

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

23
docs/source/api.md Normal file
View File

@ -0,0 +1,23 @@
# API Documentation
libWiiPy is divided up into a few subpackages to organize related features.
| Package | Description |
|--------------------------------------|-----------------------------------------------------------------|
| [libWiiPy.archive](/archive/archive) | Used to pack and extract archive formats used on the Wii |
| [libWiiPy.media](/media/media) | Used for parsing and manipulating media formats used on the Wii |
| [libWiiPy.nand](/nand/nand) | Used for working with EmuNANDs and core system files on the Wii |
| [libWiiPy.title](/title/title) | Used for parsing and manipulating Wii titles |
When using libWiiPy in your project, you can choose to either only import the package that you need, or you can use `import libWiiPy` to import the entire package, which each module being available at `libWiiPy.<package>.<module>`.
## Full Package Contents
```{toctree}
:maxdepth: 8
/archive/archive
/media/media
/nand/nand
/title/title
```

View File

@ -0,0 +1,23 @@
# libWiiPy.archive Package
## Description
The `libWiiPy.archive` package contains modules for packing and extracting archive formats used by the Wii. This currently includes packing and unpacking support for U8 archives and decompression support for ASH archives.
## Modules
| Module | Description |
|----------------------------------------|---------------------------------------------------------|
| [libWiiPy.archive.ash](/archive/ash) | Provides support for decompressing ASH archives |
| [libWiiPy.archive.lz77](/archive/lz77) | Provides support for the LZ77 compression scheme |
| [libWiiPy.archive.u8](/archive/u8) | Provides support for packing and extracting U8 archives |
## Full Package Contents
```{toctree}
:maxdepth: 4
/archive/ash
/archive/lz77
/archive/u8
```

View File

@ -0,0 +1,16 @@
# libWiiPy.archive.ash Module
## Description
The `libWiiPy.archive.ash` module provides support for handling ASH files, which are a compressed format primarily used in the Wii Menu, but also in some other titles such as My Pokémon Ranch.
At present, libWiiPy only has support for decompressing ASH files, with compression as a planned feature for the future.
## Module Contents
```{eval-rst}
.. automodule:: libWiiPy.archive.ash
:members:
:undoc-members:
:show-inheritance:
```

View File

@ -0,0 +1,14 @@
# libWiiPy.archive.lz77 Module
## Description
The `libWiiPy.archive.lz77` module provides support for handling LZ77 compression, which is a compression format used across the Wii and other Nintendo consoles.
## Module Contents
```{eval-rst}
.. automodule:: libWiiPy.archive.lz77
:members:
:undoc-members:
:show-inheritance:
```

16
docs/source/archive/u8.md Normal file
View File

@ -0,0 +1,16 @@
# libWiiPy.archive.u8 Module
## Description
The `libWiiPy.archive.u8` module provides support for handling U8 archives, which are a non-compressed archive format used extensively on the Wii to join multiple files into one.
This module exposes functions for both packing and unpacking U8 archives, as well as code to parse IMET headers. IMET headers are a header format used specifically for U8 archives containing the banner of a channel, as they store the localized name of the channel along with other banner metadata.
## Module Contents
```{eval-rst}
.. automodule:: libWiiPy.archive.u8
:members:
:undoc-members:
:show-inheritance:
```

BIN
docs/source/banner.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

BIN
docs/source/banner_old.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

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

@ -0,0 +1,40 @@
# 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'
version = 'main'
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,
"show_toc_level": 3
}
# MyST Configuration
myst_enable_extensions = ['colon_fence', 'deflist']

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

@ -0,0 +1,47 @@
---
sd_hide_title: true
---
# Overview
# libWiiPy Documentation
Welcome to the documentation website for libWiiPy! libWiiPy is a modern Python 3 library for handling the various files and formats found on the Wii.
Just need to see the API? [libWiiPy API Documentation](/api)
```{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: More
api.md
```
## Indices and tables
* [Full Index](<project:#genindex>)
* <project:#search>

View File

@ -0,0 +1,14 @@
# libWiiPy.media.banner Module
## Description
The `libWiiPy.media.banner` module is essentially a stub at this point in time. It only provides one dataclass that is likely to become a traditional class when fully implemented. It is not recommended to use this module for anything yet.
## Module Contents
```{eval-rst}
.. automodule:: libWiiPy.media.banner
:members:
:undoc-members:
:show-inheritance:
```

View File

@ -0,0 +1,19 @@
# libWiiPy.media Package
## Description
The `libWiiPy.media` package contains modules used for parsing and editing media formats used by the Wii. This currently only includes limited support for parsing channel banners.
## Modules
| Module | Description |
|----------------------------------------|---------------------------------------------------|
| [libWiiPy.media.banner](/media/banner) | Provides support for basic channel banner parsing |
## Full Package Contents
```{toctree}
:maxdepth: 4
/media/banner
```

View File

@ -0,0 +1,14 @@
# libWiiPy.nand.emunand Module
## Description
The `libWiiPy.nand.emunand` module provides support for creating and managing Wii EmuNANDs. At present, you cannot create an EmuNAND compatible with something like NEEK on a real Wii with the features provided by this library, but you can create an EmuNAND compatible with Dolphin.
## Module Contents
```{eval-rst}
.. automodule:: libWiiPy.nand.emunand
:members:
:undoc-members:
:show-inheritance:
```

23
docs/source/nand/nand.md Normal file
View File

@ -0,0 +1,23 @@
# libWiiPy.nand Package
## Description
The `libWiiPy.nand` package contains modules for parsing and manipulating EmuNANDs as well as modules for parsing and editing core system files found on the Wii's NAND.
## Modules
| Module | Description |
|----------------------------------------|----------------------------------------------------------------------------------------------------------------------------------|
| [libWiiPy.nand.emunand](/nand/emunand) | Provides support for parsing, creating, and editing EmuNANDs |
| [libWiiPy.nand.setting](/nand/setting) | Provides support for parsing, creating, and editing `setting.txt`, which is used to store the console's region and serial number |
| [libWiiPy.nand.sys](/nand/sys) | Provides support for parsing, creating, and editing `uid.sys`, which is used to store a log of all titles run on a console |
## Full Package Contents
```{toctree}
:maxdepth: 4
/nand/emunand
/nand/setting
/nand/sys
```

View File

@ -0,0 +1,16 @@
# libWiiPy.nand.setting Module
## Description
The `libWiiPy.nand.setting` module provides support for handling the Wii's `setting.txt` file. This file is stored as part of the Wii Menu's save data (stored in `/title/00000001/00000002/data/`) and is an encrypted text file that's primarily used to store your console's serial number and region information.
This module allows you to encrypt or decrypt this file, and exposes the keys stored in it for editing.
## Module Contents
```{eval-rst}
.. automodule:: libWiiPy.nand.setting
:members:
:undoc-members:
:show-inheritance:
```

14
docs/source/nand/sys.md Normal file
View File

@ -0,0 +1,14 @@
# libWiiPy.nand.sys Module
## Description
The `libWiiPy.nand.sys` module provides support for editing system files used on the Wii. Currently, it only offers support for `uid.sys`, which keeps a record of the Title IDs of every title launched on the console, assigning each one a unique ID.
## Module Contents
```{eval-rst}
.. automodule:: libWiiPy.nand.sys
:members:
:undoc-members:
:show-inheritance:
```

16
docs/source/title/cert.md Normal file
View File

@ -0,0 +1,16 @@
# libWiiPy.title.cert Module
## Description
The `libWiiPy.title.cert` module provides support for parsing the various signing certificates used by the Wii for content validation.
This module allows you to write your own code for validating the authenticity of a TMD or Ticket by providing the certificates from the Wii's certificate chain. Both retail and development certificate chains are supported.
## Module Contents
```{eval-rst}
.. automodule:: libWiiPy.title.cert
:members:
:undoc-members:
:show-inheritance:
```

View File

@ -0,0 +1,14 @@
# libWiiPy.title.commonkeys Module
## Description
The `libWiiPy.title.commonkeys` module simply provides easy access to the Wii's common encryption keys.
## Module Contents
```{eval-rst}
.. automodule:: libWiiPy.title.commonkeys
:members:
:undoc-members:
:show-inheritance:
```

View File

@ -0,0 +1,14 @@
# libWiiPy.title.content Module
## Description
The `libWiiPy.title.content` module provides support for parsing, adding, removing, and editing content files from a digital Wii title.
## Module Contents
```{eval-rst}
.. automodule:: libWiiPy.title.content
:members:
:undoc-members:
:show-inheritance:
```

View File

@ -0,0 +1,14 @@
# libWiiPy.title.crypto Module
## Description
The `libWiiPy.title.crypto` module provides low-level cryptography functions required for handling digital Wii titles. It does not expose many functions that are likely to be required during typical use, and instead acts more as a dependency for other modules.
## Module Contents
```{eval-rst}
.. automodule:: libWiiPy.title.crypto
:members:
:undoc-members:
:show-inheritance:
```

View File

@ -0,0 +1,14 @@
# libWiiPy.title.iospatcher Module
## Description
The `libWiiPy.title.iospatcher` module provides support for applying various binary patches to IOS' ES module. These patches and what they do can be found attached to the methods used to apply them.
## Module Contents
```{eval-rst}
.. automodule:: libWiiPy.title.iospatcher
:members:
:undoc-members:
:show-inheritance:
```

14
docs/source/title/nus.md Normal file
View File

@ -0,0 +1,14 @@
# libWiiPy.title.nus Module
## Description
The `libWiiPy.title.nus` module provides support for downloading digital Wii titles from the Nintendo Update Servers. This module provides easy methods for downloading TMDs, common Tickets (when present), encrypted content, and the certificate chain.
## Module Contents
```{eval-rst}
.. automodule:: libWiiPy.title.nus
:members:
:undoc-members:
:show-inheritance:
```

View File

@ -0,0 +1,14 @@
# libWiiPy.title.ticket Module
## Description
The `libWiiPy.title.ticket` module provides support for handling Tickets, which are the license files used to decrypt the content of digital titles during installation. This module allows for easy parsing and editing of Tickets.
## Module Contents
```{eval-rst}
.. automodule:: libWiiPy.title.ticket
:members:
:undoc-members:
:show-inheritance:
```

View File

@ -0,0 +1,38 @@
# libWiiPy.title Package
## Description
The `libWiiPy.title` package contains modules for interacting with Wii titles. This is the most complete package in libWiiPy, as it offers the functionality one would be most likely to need. As a result, it gets the most attention during development and should be the most reliable.
## Modules
| Module | Description |
|------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------|
| [libWiiPy.title.cert](/title/cert) | Provides support for parsing and validating the certificates used for title verification |
| [libWiiPy.title.commonkeys](/title/commonkeys) | Provides easy access to all common encryption keys |
| [libWiiPy.title.content](/title/content) | Provides support for parsing and editing content included as part of digital titles |
| [libWiiPy.title.crypto](/title/crypto) | Provides low-level cryptography functions used to handle encryption in other modules |
| [libWiiPy.title.iospatcher](/title/iospatcher) | Provides an easy interface to apply patches to IOSes |
| [libWiiPy.title.nus](/title/nus) | Provides support for downloading TMDs, Tickets, encrypted content, and the certificate chain from the Nintendo Update Servers |
| [libWiiPy.title.ticket](/title/ticket) | Provides support for parsing and editing Tickets used for content decryption |
| [libWiiPy.title.title](/title/title.title) | Provides high-level support for parsing and editing an entire title with the context of each component |
| [libWiiPy.title.tmd](/title/tmd) | Provides support for parsing and editing TMDs (Title Metadata) |
| [libWiiPy.title.util](/title/util) | Provides some simple utility functions relating to titles |
| [libWiiPy.title.wad](/title/wad) | Provides support for parsing and editing WAD files, allowing you to load each component into the other available classes |
## Full Package Contents
```{toctree}
:maxdepth: 4
/title/cert
/title/commonkeys
/title/content
/title/crypto
/title/iospatcher
/title/nus
/title/ticket
/title/title.title
/title/tmd
/title/util
/title/wad
```

View File

@ -0,0 +1,17 @@
# libWiiPy.title.title Module
## Description
The `libWiiPy.title.title` module provides a high-level interface for handling all the components of a digital Wii title through one class. It allows for directly importing a WAD, and will automatically extract the various components and load them into their appropriate classes. Additionally, it provides duplicates of some methods found in those classes that require fewer arguments, as it has the context of the other components and is able to retrieve additional data automatically.
An example of that idea can be seen with the method `get_content_by_index()`. In its original definition, which can be seen at <project:#libWiiPy.title.content.ContentRegion.get_content_by_index>, you are required to supply the Title Key for the title that the content is sourced from. In contrast, when using <project:#libWiiPy.title.title.Title.get_content_by_index>, you do not need to supply a Title Key, as the Title object already has the context of the Ticket and can retrieve the Title Key from it automatically. In a similar vein, this module provides the easiest route for verifying that a title is legitimately signed by Nintendo. The method <project:#libWiiPy.title.title.Title.get_is_signed> is able to access the entire certificate chain, the TMD, and the Ticket, and is therefore able to verify all components of the title by itself.
Because using <project:#libWiiPy.title.title.Title> allows many operations to be much simpler than if you manage the components separately, it's generally recommended to use it whenever possible.
## Module Contents
```{eval-rst}
.. automodule:: libWiiPy.title.title
:members:
:undoc-members:
```

14
docs/source/title/tmd.md Normal file
View File

@ -0,0 +1,14 @@
# libWiiPy.title.tmd Module
## Description
The `libWiiPy.title.tmd` module provides support for handling TMD (Title Metadata) files, which contain the metadata of both digital and physical Wii titles. This module allows for easy parsing and editing of TMDs.
## Module Contents
```{eval-rst}
.. automodule:: libWiiPy.title.tmd
:members:
:undoc-members:
:show-inheritance:
```

14
docs/source/title/util.md Normal file
View File

@ -0,0 +1,14 @@
# libWiiPy.title.util Module
## Description
The `libWiiPy.title.util` module provides common utility functions internally. It is not designed to be used directly.
## Module Contents
```{eval-rst}
.. automodule:: libWiiPy.title.util
:members:
:undoc-members:
:show-inheritance:
```

14
docs/source/title/wad.md Normal file
View File

@ -0,0 +1,14 @@
# libWiiPy.title.wad Module
## Description
The `libWiiPy.title.wad` module provides support for handling WAD (Wii Archive Data) files, which is the format used to deliver digital Wii titles. This module allows for extracting the various components for a WAD, as well as properly padding and writing out that data when it has been edited using other modules.
## Module Contents
```{eval-rst}
.. automodule:: libWiiPy.title.wad
:members:
:undoc-members:
:show-inheritance:
```

View File

@ -0,0 +1,139 @@
# 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 chain, which is used 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. Certificate chains also have a class that we'll cover after the main three components, but the latter two components don't have data we can edit, so 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 Certificate Chain
As mentioned at the start of this guide, WADs also contain a certificate chain. We don't necessarily need this data right now, but getting it is very similar to the other components:
```pycon
>>> certificate_chain = libWiiPy.title.CertificateChain()
>>> certificate_chain.load(wad.get_cert_data())
>>>
```
### The Other Data
Also mentioned earlier in this guide, WADs may contain two additional regions of data know as the footer (or "meta"), 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
>>> 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 hear more, so feel free to email me at [ninjacheetah@ncxprogramming.com](mailto:ninjacheetah@ncxprogramming.com).
:::
<hr>
Now, that might all seem a bit complicated. What if instead there was a way to manage a title using one object that handles all the individual components for you? Well, you're in luck! On top of the fairly low-level way to extract a WAD provided in this guide, libWiiPy also offers a higher-level method through the <project:#libWiiPy.title.title> module. On the next page, we'll dive into the specifics, and how to use this module.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
[project]
name = "libWiiPy"
version = "0.2.2"
version = "0.6.1"
authors = [
{ name="NinjaCheetah", email="ninjacheetah@ncxprogramming.com" },
{ name="Lillian Skinner", email="lillian@randommeaninglesscharacters.com" }
@ -9,18 +9,29 @@ description = "A modern Python library for handling files used by the Wii"
readme = "README.md"
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"
Issues = "https://github.com/NinjaCheetah/libWiipy/issues"
Documentation = "https://ninjacheetah.github.io/libWiiPy/"
Repository = "https://github.com/NinjaCheetah/libWiiPy.git"
Issues = "https://github.com/NinjaCheetah/libWiiPy/issues"
[build-system]
requires = ["setuptools>=61.0"]

View File

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

0
src/__init__.py Normal file
View File

View File

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

View File

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

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

@ -0,0 +1,233 @@
# "archive/ash.py" from libWiiPy by NinjaCheetah & Contributors
# https://github.com/NinjaCheetah/libWiiPy
#
# This code in particular is a direct translation of "ash-dec" from ASH0-tools. ASH0-tools is written by Garhoogin and
# co-authored by NinjaCheetah.
# https://github.com/NinjaCheetah/ASH0-tools
#
# See <link pending> for details about the ASH compression 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

View File

@ -0,0 +1,291 @@
# "archive/lz77.py" from libWiiPy by NinjaCheetah & Contributors
# https://github.com/NinjaCheetah/libWiiPy
#
# See https://wiibrew.org/wiki/LZ77 for details about the LZ77 compression format.
import io
from dataclasses import dataclass as _dataclass
from typing import List as _List
_LZ_MIN_DISTANCE = 0x01 # Minimum distance for each reference.
_LZ_MAX_DISTANCE = 0x1000 # Maximum distance for each reference.
_LZ_MIN_LENGTH = 0x03 # Minimum length for each reference.
_LZ_MAX_LENGTH = 0x12 # Maximum length for each reference.
@_dataclass
class _LZNode:
dist: int = 0
len: int = 0
weight: int = 0
def _compress_compare_bytes(buffer: _List[int], offset1: int, offset2: int, abs_len_max: int) -> int:
# Compare bytes up to the maximum length we can match. Start by comparing the first 3 bytes, since that's the
# minimum match length and this allows for a more optimized early exit.
num_matched = 0
while num_matched < abs_len_max:
if buffer[offset1 + num_matched] != buffer[offset2 + num_matched]:
break
num_matched += 1
return num_matched
def _compress_search_matches_optimized(buffer: _List[int], pos: int) -> (int, int):
bytes_left = len(buffer) - pos
global _LZ_MAX_DISTANCE, _LZ_MIN_LENGTH, _LZ_MAX_LENGTH, _LZ_MIN_DISTANCE
# Default to only looking back 4096 bytes, unless we've moved fewer than 4096 bytes, in which case we should
# only look as far back as we've gone.
max_dist = min(_LZ_MAX_DISTANCE, pos)
# Default to only matching up to 18 bytes, unless fewer than 18 bytes remain, in which case we can only match
# up to that many bytes.
max_len = min(_LZ_MAX_LENGTH, bytes_left)
# Log the longest match we found and its offset.
biggest_match, biggest_match_pos = 0, 0
# Search for matches.
for i in range(_LZ_MIN_DISTANCE, max_dist + 1):
num_matched = _compress_compare_bytes(buffer, pos - i, pos, max_len)
if num_matched > biggest_match:
biggest_match = num_matched
biggest_match_pos = i
if biggest_match == max_len:
break
return biggest_match, biggest_match_pos
def _compress_search_matches_greedy(buffer: _List[int], pos: int) -> (int, int):
# Finds and returns the first valid match, rather that finding the best one.
bytes_left = len(buffer) - pos
global _LZ_MAX_DISTANCE, _LZ_MAX_LENGTH, _LZ_MIN_DISTANCE
# Default to only looking back 4096 bytes, unless we've moved fewer than 4096 bytes, in which case we should
# only look as far back as we've gone.
max_dist = min(_LZ_MAX_DISTANCE, pos)
# Default to only matching up to 18 bytes, unless fewer than 18 bytes remain, in which case we can only match
# up to that many bytes.
max_len = min(_LZ_MAX_LENGTH, bytes_left)
match, match_pos = 0, 0
for i in range(_LZ_MIN_DISTANCE, max_dist + 1):
match = _compress_compare_bytes(buffer, pos - i, pos, max_len)
match_pos = i
if match >= _LZ_MIN_LENGTH or match == max_len:
break
return match, match_pos
def _compress_node_is_ref(node: _LZNode) -> bool:
return node.len >= _LZ_MIN_LENGTH
def _compress_get_node_cost(length: int) -> int:
if length >= _LZ_MIN_LENGTH:
num_bytes = 2
else:
num_bytes = 1
return 1 + (num_bytes * 8)
def _compress_lz77_optimized(data: bytes) -> bytes:
# Optimized compressor based around a node graph that finds optimal string matches. Originally the default
# implementation, but unfortunately it's very slow.
nodes = [_LZNode() for _ in range(len(data))]
# Iterate over the uncompressed data, starting from the end.
pos = len(data)
global _LZ_MAX_LENGTH, _LZ_MIN_LENGTH, _LZ_MIN_DISTANCE
data_list = list(data)
while pos:
pos -= 1
node = nodes[pos]
# Limit the maximum search length when we're near the end of the file.
max_search_len = min(_LZ_MAX_LENGTH, len(data_list) - pos)
if max_search_len < _LZ_MIN_DISTANCE:
max_search_len = 1
# Initialize as 1 for each, since that's all we could use if we weren't compressing.
length, dist = 1, 1
if max_search_len >= _LZ_MIN_LENGTH:
length, dist = _compress_search_matches_optimized(data_list, pos)
# Treat as direct bytes if it's too short to copy.
if length == 0 or length < _LZ_MIN_LENGTH:
length = 1
# If the node goes to the end of the file, the weight is the cost of the node.
if (pos + length) == len(data_list):
node.len = length
node.dist = dist
node.weight = _compress_get_node_cost(length)
# Otherwise, search for possible matches and determine the one with the best cost.
else:
weight_best = 0xFFFFFFFF # This was originally UINT_MAX, but that isn't a thing here so 32-bit it is!
len_best = 1
while length:
weight_next = nodes[pos + length].weight
weight = _compress_get_node_cost(length) + weight_next
if weight < weight_best:
len_best = length
weight_best = weight
length -= 1
if length != 0 and length < _LZ_MIN_LENGTH:
length = 1
node.len = len_best
node.dist = dist
node.weight = weight_best
# Write the compressed data.
with io.BytesIO() as buffer:
# Write the header data.
buffer.write(b'LZ77\x10') # The LZ type on the Wii is *always* 0x10.
buffer.write(len(data).to_bytes(3, 'little'))
src_pos = 0
while src_pos < len(data):
head = 0
head_pos = buffer.tell()
buffer.write(b'\x00') # Reserve a byte for the chunk head.
i = 0
while i < 8 and src_pos < len(data):
current_node = nodes[src_pos]
length = current_node.len
dist = current_node.dist
# This is a reference node.
if _compress_node_is_ref(current_node):
encoded = (((length - _LZ_MIN_LENGTH) & 0xF) << 12) | ((dist - _LZ_MIN_DISTANCE) & 0xFFF)
buffer.write(encoded.to_bytes(2))
head = (head | (1 << (7 - i))) & 0xFF
# This is a direct copy node.
else:
buffer.write(data[src_pos:src_pos + 1])
src_pos += length
i += 1
pos = buffer.tell()
buffer.seek(head_pos)
buffer.write(head.to_bytes(1))
buffer.seek(pos)
buffer.seek(0)
out_data = buffer.read()
return out_data
def _compress_lz77_greedy(data: bytes) -> bytes:
# Greedy compressor that processes the file start to end and saves the first matches found. Faster than the
# optimized implementation, but creates larger files.
global _LZ_MAX_LENGTH, _LZ_MIN_LENGTH, _LZ_MIN_DISTANCE
with io.BytesIO() as buffer:
# Write the header data.
buffer.write(b'LZ77\x10') # The LZ type on the Wii is *always* 0x10.
buffer.write(len(data).to_bytes(3, 'little'))
src_pos = 0
data_list = list(data)
while src_pos < len(data):
head = 0
head_pos = buffer.tell()
buffer.write(b'\x00') # Reserve a byte for the chunk head.
i = 0
while i < 8 and src_pos < len(data):
length, dist = _compress_search_matches_greedy(data_list, src_pos)
# This is a reference node.
if length >= _LZ_MIN_LENGTH:
encoded = (((length - _LZ_MIN_LENGTH) & 0xF) << 12) | ((dist - _LZ_MIN_DISTANCE) & 0xFFF)
buffer.write(encoded.to_bytes(2))
head = (head | (1 << (7 - i))) & 0xFF
src_pos += length
# This is a direct copy node.
else:
buffer.write(data[src_pos:src_pos + 1])
src_pos += 1
i += 1
pos = buffer.tell()
buffer.seek(head_pos)
buffer.write(head.to_bytes(1))
buffer.seek(pos)
buffer.seek(0)
out_data = buffer.read()
return out_data
def compress_lz77(data: bytes, compression_level: int = 1) -> bytes:
"""
Compresses data using the Wii's LZ77 compression algorithm and returns the compressed result. Supports two
different levels of compression, one based around a "greedy" LZ compression algorithm and the other based around
an optimized LZ compression algorithm. The greedy compressor, level 1, will produce a larger compressed file but
will run noticeably faster than the optimized compressor, which is level 2, especially for larger data.
Parameters
----------
data: bytes
The data to compress.
compression_level: int
The compression level to use, either 1 and 2. Default value is 1.
Returns
-------
bytes
The LZ77-compressed data.
"""
if compression_level == 1:
out_data = _compress_lz77_greedy(data)
elif compression_level == 2:
out_data = _compress_lz77_optimized(data)
else:
raise ValueError(f"Invalid compression level \"{compression_level}\"!\"")
return out_data
def decompress_lz77(lz77_data: bytes) -> bytes:
"""
Decompresses LZ77-compressed data and returns the decompressed result. Supports data both with and without the
magic number 'LZ77' (which may not be present if the data is embedded in something else).
Parameters
----------
lz77_data: bytes
The LZ77-compressed data to decompress.
Returns
-------
bytes
The decompressed data.
"""
with io.BytesIO(lz77_data) as data:
magic = data.read(4)
# Assume if we didn't get the magic number that this data starts without it.
if magic != b'LZ77':
data.seek(0)
# Other compression types are used by Nintendo, but only type 0x10 was used on the Wii.
compression_type = int.from_bytes(data.read(1))
if compression_type != 0x10:
raise ValueError("This data is using an unsupported compression type!")
decompressed_size = int.from_bytes(data.read(3), byteorder='little')
# Use an integer list for storing decompressed data, this is much faster than using (and appending to) a
# bytes object.
out_data = [0] * decompressed_size
pos = 0
while pos < decompressed_size:
flag = int.from_bytes(data.read(1))
# Read bits in the flag from most to least significant.
for x in range(7, -1, -1):
# Avoids a buffer overrun if the final flag isn't fully used.
if pos >= decompressed_size:
break
# Result of 1, this means we're copying bytes from earlier in the data.
if flag & (1 << x):
reference = int.from_bytes(data.read(2))
length = 3 + ((reference >> 12) & 0xF)
offset = pos - (reference & 0xFFF) - 1
for _ in range(length):
out_data[pos] = out_data[offset]
pos += 1
offset += 1
# Avoids a buffer overrun if the copy length would extend past the end of the file.
if pos >= decompressed_size:
break
# Result of 0, use the next byte directly.
else:
out_data[pos] = int.from_bytes(data.read(1))
pos += 1
out_bytes = bytes(out_data)
return out_bytes

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

@ -0,0 +1,555 @@
# "archive/u8.py" from libWiiPy by NinjaCheetah & Contributors
# https://github.com/NinjaCheetah/libWiiPy
#
# See https://wiibrew.org/wiki/U8_archive for details about the U8 archive format.
import binascii
import hashlib
import io
import os
import pathlib
from enum import IntEnum as _IntEnum
from dataclasses import dataclass as _dataclass
from typing import List, Tuple
from ..shared import _align_value, _pad_bytes
@_dataclass
class _U8Node:
"""
A U8Node object that contains the data of a single node in a U8 file header. Each node keeps track of whether this
node is for a file or directory, the offset of the name of the file/directory, the offset of the data for the file/
directory, and the size of the data. Private class used by functions and methods in the U8 module.
Attributes
----------
type : int
Whether this node refers to a file or a directory. Either 0x0000 for files, or 0x0100 for directories.
name_offset : int
The offset of the name of the file/directory this node refers to.
data_offset : int
The offset of the data for the file/directory this node refers to.
size : int
The size of the data for this node.
"""
type: int
name_offset: int
data_offset: int
size: int
class U8Archive:
"""
A U8 object that allows for parsing and editing the contents of a U8 archive.
Attributes
----------
u8_node_list : List[_U8Node]
A list of U8Node objects representing the nodes of the U8 archive.
file_name_list : List[str]
A list of the names of the files in the U8 archive.
file_data_list : List[bytes]
A list of file data for the files in the U8 archive; corresponds with file_name_list.
header_size : int
The size of the U8 archive header.
data_offset : int
The offset of the data region of the U8 archive.
imet_header: IMETHeader
The IMET header of the U8 archive, if one exists. Otherwise, an empty IMETHeader object.
"""
def __init__(self):
self.u8_magic = b''
self.u8_node_list: List[_U8Node] = [] # All the nodes in the header of a U8 file.
self.file_name_list: List[str] = []
self.file_data_list: List[bytes] = []
self.root_node_offset: int = 0
self.header_size: int = 0
self.data_offset: int = 0
self.root_node: _U8Node = _U8Node(0, 0, 0, 0)
self.imet_header: IMETHeader = IMETHeader()
def load(self, u8_data: bytes) -> None:
"""
Loads raw U8 data into a new U8 object. This allows for extracting the file and updating its contents.
Parameters
----------
u8_data : bytes
The data for the U8 file to load.
"""
with io.BytesIO(u8_data) as u8_data:
# Read the first 4 bytes of the file to ensure that it's a U8 archive.
u8_data.seek(0x0)
self.u8_magic = u8_data.read(4)
if self.u8_magic != b'\x55\xAA\x38\x2D':
# Check for an IMET header, if the file doesn't start with the proper magic number. The header magic
# may be at either 0x40 or 0x80 depending on whether this title has a build tag at the start or not.
u8_data.seek(0x40)
self.u8_magic = u8_data.read(4)
if self.u8_magic == b'\x49\x4D\x45\x54':
# IMET with no build tag means the U8 archive should start at 0x600.
u8_data.seek(0x600)
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!")
# Parse the IMET header, then continue parsing the U8 archive.
u8_data.seek(0x0)
self.imet_header.load(u8_data.read(0x600))
else:
# This check will pass if the IMET comes after a build tag.
u8_data.seek(0x80)
self.u8_magic = u8_data.read(4)
if self.u8_magic == b'\x49\x4D\x45\x54':
# IMET with a build tag means the U8 archive should start at 0x640.
u8_data.seek(0x640)
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!")
# Parse the IMET header, then continue parsing the U8 archive.
u8_data.seek(0x40)
self.imet_header.load(u8_data.read(0x600))
else:
raise TypeError("This is not a valid U8 archive!")
# Offset of the root node, which will always be 0x20.
self.root_node_offset = int.from_bytes(u8_data.read(4))
# The size of the U8 header.
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.
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(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))
# Iterate over all loaded nodes and create a list of file names and a list of file data.
name_base_offset = u8_data.tell()
for node in self.u8_node_list:
u8_data.seek(name_base_offset + node.name_offset)
name_bin = b''
while name_bin[-1:] != b'\x00':
name_bin += u8_data.read(1)
name_bin = name_bin[:-1]
name = str(name_bin.decode())
self.file_name_list.append(name)
if node.type == 0:
u8_data.seek(node.data_offset)
self.file_data_list.append(u8_data.read(node.size))
else:
self.file_data_list.append(b'')
def dump(self) -> bytes:
"""
Dumps the U8Archive object into the raw data of a U8 archive.
Returns
-------
bytes
The full U8 archive as bytes.
"""
# This is 0 because the header size DOES NOT include the initial 32 bytes describing the file.
header_size = 0
# Add 12 bytes for each node, since that's how many bytes each one is made up of.
for node in range(len(self.u8_node_list)):
header_size += 12
# Add the number of bytes used for each file/folder name in the string table.
for file_name in self.file_name_list:
header_size += len(file_name) + 1
# The initial data offset is equal to the file header (32 bytes) + node data aligned to 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 = _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'
# Root node offset (this is always 0x20).
u8_data += int.to_bytes(0x20, 4)
# Size of the file header (excluding the first 32 bytes).
u8_data += int.to_bytes(header_size, 4)
# Offset of the beginning of the data region of the U8 archive.
u8_data += int.to_bytes(data_offset, 4)
# 16 bytes of zeroes.
u8_data += (b'\x00' * 16)
# Iterate over all the U8 nodes and dump them.
for node in self.u8_node_list:
u8_data += int.to_bytes(node.type, 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.
u8_data = _pad_bytes(u8_data, 64)
# Iterate all file data and dump it.
for file in self.file_data_list:
u8_data += _pad_bytes(file, 32)
# Return the U8 archive.
return u8_data
def extract_u8(u8_data, output_folder) -> None:
"""
Extracts the provided U8 archive file data into the provided output folder path. Note that the folder must not
already exist to ensure that the output can correctly represent the file structure of the original U8 archive.
Parameters
----------
u8_data : bytes
The data for the U8 file to extract.
output_folder : str
The path to a new folder to extract the archive to.
"""
output_folder = pathlib.Path(output_folder)
# Check if the path already exists, and if it does, ensure that it is both a directory and empty.
if output_folder.exists():
if output_folder.is_dir() and next(os.scandir(output_folder), None):
raise ValueError("Output folder is not empty!")
elif output_folder.is_file():
raise ValueError("A file already exists with the provided name!")
else:
os.mkdir(output_folder)
# Create a new U8Archive object and load the provided U8 file data into it.
u8_archive = U8Archive()
u8_archive.load(u8_data)
# This variable stores the path of the directory we're currently processing.
current_dir = output_folder
# This variable stores the 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 (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])
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:
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(f"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, 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. -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, -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.
for directory in dir_list:
node_count += 1
u8_archive.file_name_list.append(directory)
u8_archive.file_data_list.append(b'')
max_node = node_count + sum(1 for _ in current_path.joinpath(directory).rglob('*'))
u8_archive.u8_node_list.append(_U8Node(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
def pack_u8(input_path, generate_imet=False, imet_titles:List[str]=None) -> bytes:
"""
Packs the provided file or folder into a new U8 archive, and returns the raw file data for it.
To generate an IMET header for this U8 archive, the archive must contain the required banner files "icon.bin",
"banner.bin", and "sound.bin", because the sizes of these files are stored in the header.
Parameters
----------
input_path
The path to the input file or folder.
generate_imet : bool, optional
Whether an IMET header should be generated for this U8 archive or not. IMET headers are only used for channel
banners (00000000.app), and required banner files must exist to generate this header. Defaults to False.
imet_titles : List[str], optional
A list of the channel title in different languages for the IMET header. If only one item is provided, that
item will be used for all entries in the header. Defaults to None, and is only used when generate_imet is True.
Returns
-------
u8_archive : bytes
The data for the packed U8 archive.
"""
input_path = pathlib.Path(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(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, parent_node=0)
if generate_imet:
print("gen imet")
return u8_archive.dump()
elif input_path.is_file():
raise ValueError("This does not appear to be a directory.")
else:
raise FileNotFoundError(f"Input directory: \"{input_path}\" does not exist!")
class IMETHeader:
"""
An IMETHeader object that allows for parsing, editing, and generating an IMET header. These headers precede the
data of a channel banner (00000000.app), and are used to store metadata about the banner and verify its data.
An IMET header is always 1,536 (0x600) bytes long.
Attributes
----------
magic : str
Magic number for the header, should be "IMD5".
header_size : int
Length of the M
imet_version : int
Version of the IMET header. Normally always 3.
sizes : List[int]
The file sizes of icon.bin, banner.bin, and sound.bin.
flag1 : int
Unknown.
channel_names : List[str]
The name of the channel this header is for in Japanese, English, German, French, Spanish, Italian, Dutch,
Simplified Chinese, Traditional Chinese, and Korean, in that order.
md5_hash : bytes
MD5 sum of the entire header, with this field being all zeros during the hashing.
"""
def __init__(self):
self.magic: str = "" # Should always be "IMET"
self.header_size: int = 0 # Always 1536? I assumed this would mean something, but it's just the header length.
self.imet_version: int = 0 # Always 3?
self.sizes: List[int] = [] # Should only have 3 items
self.flag1: int = 0 # Unknown
self.channel_names: List[str] = [] # Should have 10 items
self.md5_hash: bytes = b''
class LocalizedTitles(_IntEnum):
TITLE_JAPANESE = 0
TITLE_ENGLISH = 1
TITLE_GERMAN = 2
TITLE_FRENCH = 3
TITLE_SPANISH = 4
TITLE_ITALIAN = 5
TITLE_DUTCH = 6
TITLE_CHINESE_SIMPLIFIED = 7
TITLE_CHINESE_TRADITIONAL = 8
TITLE_KOREAN = 9
def load(self, imet_data: bytes) -> None:
"""
Loads the raw data of an IMET header.
Parameters
----------
imet_data : bytes
The data for the IMET header to load.
"""
with io.BytesIO(imet_data) as data:
data.seek(0x40)
self.magic = str(data.read(4).decode())
self.header_size = int.from_bytes(data.read(4))
self.imet_version = int.from_bytes(data.read(4))
self.sizes = []
for _ in range(0, 3):
self.sizes.append(int.from_bytes(data.read(4)))
self.flag1 = int.from_bytes(data.read(4))
self.channel_names = []
for _ in range(0, 10):
# Read the translated channel name from the header, then drop all trailing null bytes. The encoding
# used here is UTF-16 Big Endian.
new_channel_name = data.read(84)
self.channel_names.append(str(new_channel_name.decode('utf-16-be')).replace('\x00', ''))
data.seek(data.tell() + 588)
self.md5_hash = binascii.hexlify(data.read(16))
def dump(self) -> bytes:
"""
Dump the IMETHeader back into raw bytes.
Returns
-------
bytes
The IMET header as bytes.
"""
imet_data = b''
# 64 bytes of padding.
imet_data += b'\x00' * 64
# "IMET" magic number.
imet_data += str.encode("IMET")
# IMET header size. TODO: check if this is actually always 1536
imet_data += int.to_bytes(1536, 4)
# IMET header version.
imet_data += int.to_bytes(self.imet_version, 4)
# Banner component sizes.
for size in self.sizes:
imet_data += int.to_bytes(size, 4)
# flag1.
imet_data += int.to_bytes(self.flag1, 4)
# Channel names.
for channel_name in self.channel_names:
new_channel_name = channel_name.encode('utf-16-be')
while len(new_channel_name) < 84:
new_channel_name += b'\x00'
imet_data += new_channel_name
# 588 (WHY??) bytes of padding.
imet_data += b'\x00' * 588
# MD5 hash. To calculate the real one, we need to write all zeros to it first, then hash the entire header with
# the zero hash. After that we'll replace this hash with the calculated one.
imet_data += b'\x00' * 16
imet_hash = hashlib.md5(imet_data)
imet_data = imet_data[:-16] + imet_hash.digest()
return imet_data
def create(self, sizes: List[int], channel_names: Tuple[int, str] | List[Tuple[int, str]]) -> None:
"""
Create a new IMET header, specifying the sizes of the banner components and one or more localized channel names.
Parameters
----------
sizes : List[int]
The size in bytes of icon.bin, banner.bin, and sound.bin, in that order.
channel_names : Tuple(int, str), List[Tuple[int, str]]
A pair or list of pairs of the target language and channel name for that language. Target languages are
defined in LocalizedTitles.
See Also
--------
libWiiPy.archive.u8.IMETHeader.LocalizedTitles
"""
# Begin by setting the constant values before we parse the input.
self.magic = "IMET"
self.header_size = 1536
self.imet_version = 3
self.flag1 = 0 # Still not really sure about this one.
# Validate the number of entries, then set the provided file sizes.
if len(sizes) != 3:
raise ValueError("You must supply 3 file sizes to generate an IMET header!")
self.sizes = sizes
# Now we can parse the channel names. This functions the same as setting them later, so just calling
# set_channel_names() is the most practical.
self.channel_names = ["" for _ in range(0, 10)]
self.set_channel_names(channel_names)
def get_channel_names(self, target_languages: int | List[int]) -> str | List[str]:
"""
Get one or more channel names from the IMET header based on the specified languages.
Parameters
----------
target_languages : int, List[int, str]
One or more target languages. Target languages are defined in LocalizedTitles.
Returns
-------
str, List[str]
The channel name for the specified language, or a list of channel names in the same order as the specified
languages.
See Also
--------
libWiiPy.archive.u8.IMETHeader.LocalizedTitles
"""
# Flatten single instance of LocalizedTitles being passed to a proper int.
if isinstance(target_languages, self.LocalizedTitles):
target_languages = int(target_languages)
# If only one channel name was requested.
if type(target_languages) == int:
if target_languages not in self.LocalizedTitles:
raise ValueError(f"The specified language is not valid!")
return self.channel_names[target_languages]
# If multiple channel names were requested.
else:
channel_names = []
for lang in target_languages:
if lang not in self.LocalizedTitles:
raise ValueError(f"The specified language at index {target_languages.index(lang)} is not valid!")
channel_names.append(self.channel_names[lang])
return channel_names
def set_channel_names(self, channel_names: Tuple[int, str] | List[Tuple[int, str]]) -> None:
"""
Specify one or more new channel names to set in the IMET header.
Parameters
----------
channel_names : Tuple(int, str), List[Tuple[int, str]]
A pair or list of pairs of the target language and channel name for that language. Target languages are
defined in LocalizedTitles.
See Also
--------
libWiiPy.archive.u8.IMETHeader.LocalizedTitles
"""
# If only one channel name was provided.
if type(channel_names) == tuple:
if channel_names[0] not in self.LocalizedTitles:
raise ValueError(f"The target language \"{channel_names[0]}\" is not valid!")
if len(channel_names[1]) > 42:
raise ValueError(f"The channel name \"{channel_names[1]}\" is too long! Channel names cannot exceed "
f"42 characters!")
self.channel_names[channel_names[0]] = channel_names[1]
# If a list of channel names was provided.
else:
for name in channel_names:
if name[0] not in self.LocalizedTitles:
raise ValueError(f"The target language \"{name[0]}\" for the name at index {channel_names.index(name)} "
f"is not valid!")
if len(name[1]) > 42:
raise ValueError(f"The channel name \"{name[1]}\" at index {channel_names.index(name)} is too long! "
f"Channel names cannot exceed 42 characters!")
self.channel_names[name[0]] = name[1]

View File

@ -1,354 +0,0 @@
# "content.py" from libWiiPy by NinjaCheetah & Contributors
# https://github.com/NinjaCheetah/libWiiPy
#
# See https://wiibrew.org/wiki/Title for details about how titles are formatted
import io
import sys
import hashlib
from typing import List
from .types import ContentRecord
from .crypto import decrypt_content, encrypt_content
class ContentRegion:
"""
A ContentRegion object to parse the continuous content region of a WAD. Allows for retrieving content from the
region in both encrypted or decrypted form, and setting new content.
Attributes
----------
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_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:
"""
Loads the raw content region and builds a list of all the contents.
Parameters
----------
content_region : bytes
The raw data for the content region being loaded.
content_records : list[ContentRecord]
A list of ContentRecord objects detailing all contents contained in the region.
"""
self.content_records = content_records
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.
# Because of this, we need to add bytes to the next 64 byte offset if the previous content wasn't that long.
for content in self.content_records[:-1]:
start_offset = content.content_size + self.content_start_offsets[-1]
if (content.content_size % 64) != 0:
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)):
# 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.
bytes_to_read = self.content_records[content].content_size
if (bytes_to_read % 16) != 0:
bytes_to_read += 16 - (bytes_to_read % 16)
# Read the file based on the size of the content in the associated record, then append that data to
# the list of content.
content_enc = content_region_data.read(bytes_to_read)
self.content_list.append(content_enc)
def dump(self) -> bytes:
"""
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
content list matches the raw data.
Returns
-------
bytes
The full WAD file as bytes.
"""
# Open the stream and begin writing data to it.
with io.BytesIO() as content_region_data:
for content in self.content_list:
# Calculate padding after this content before the next one.
padding_bytes = 0
if (len(content) % 64) != 0:
padding_bytes = 64 - (len(content) % 64)
# Write content data, then the padding afterward if necessary.
content_region_data.write(content)
if padding_bytes > 0:
content_region_data.write(b'\x00' * padding_bytes)
content_region_data.seek(0x0)
content_region_raw = content_region_data.read()
# Return the raw ContentRegion for the data contained in the object.
return content_region_raw
def get_enc_content_by_index(self, index: int) -> bytes:
"""
Gets an individual content from the content region based on the provided index, in encrypted form.
Parameters
----------
index : int
The index of the content you want to get.
Returns
-------
bytes
The encrypted content listed in the content record.
"""
content_enc = self.content_list[index]
return content_enc
def get_enc_content_by_cid(self, cid: int) -> bytes:
"""
Gets an individual content from the content region based on the provided Content ID, in encrypted form.
Parameters
----------
cid : int
The Content ID of the content you want to get. Expected to be in decimal form.
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.
content_enc = self.get_enc_content_by_index(content_index)
return content_enc
def get_enc_contents(self) -> List[bytes]:
"""
Gets a list of all encrypted contents from the content region.
Returns
-------
List[bytes]
A list containing all encrypted contents.
"""
return self.content_list
def get_content_by_index(self, index: int, title_key: bytes) -> bytes:
"""
Gets an individual content from the content region based on the provided index, in decrypted form.
Parameters
----------
index : int
The index of the content you want to get.
title_key : bytes
The Title Key for the title the content is from.
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.
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)
# 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))
return content_dec
def get_content_by_cid(self, cid: int, title_key: bytes) -> 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.
title_key : bytes
The Title Key for the title the content is from.
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)
return content_dec
def get_contents(self, title_key: bytes) -> List[bytes]:
"""
Gets a list of all contents from the content region, in decrypted form.
Parameters
----------
title_key : bytes
The Title Key for the title the content is from.
Returns
-------
List[bytes]
A list containing all decrypted contents.
"""
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))
return dec_contents
def set_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.
Parameters
----------
enc_content : bytes
The new encrypted content to set.
cid : int
The Content ID to assign the new content in the content record.
index : int
The index to place the new content at.
content_type : int
The type of the new content.
content_size : int
The size of the new encrypted content when decrypted.
content_hash : bytes
The hash of the new encrypted content when decrypted.
"""
# 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
def set_content(self, dec_content: bytes, cid: int, index: int, content_type: int, title_key: bytes) -> None:
"""
Sets the provided index to a new content with the provided Content ID. Hashes and size of the content are
set in the content record, with a new record being added if necessary.
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.
"""
# Store the size of the new content.
dec_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)
# Pass values to set_enc_content()
self.set_enc_content(enc_content, cid, index, content_type, dec_content_size, dec_content_hash)
def load_enc_content(self, enc_content: bytes, index: int) -> None:
"""
Loads the provided encrypted content into the content region at the specified index, with the assumption that
it matches the record at that index. Not recommended for most use cases, use decrypted content and
load_content() instead.
Parameters
----------
enc_content : bytes
The encrypted content to load.
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
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.
Parameters
----------
dec_content : bytes
The decrypted content to load.
index : int
The content 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.")
# 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))
# 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)
else:
self.content_list[index] = enc_content

View File

@ -0,0 +1,4 @@
# "media/__init__.py" from libWiiPy by NinjaCheetah & Contributors
# https://github.com/NinjaCheetah/libWiiPy
from .banner import *

View File

@ -0,0 +1,31 @@
# "title/banner.py" from libWiiPy by NinjaCheetah & Contributors
# https://github.com/NinjaCheetah/libWiiPy
#
# See https://wiibrew.org/wiki/Opening.bnr for details about the Wii's banner format
from dataclasses import dataclass as _dataclass
@_dataclass
class IMD5Header:
"""
An IMD5Header object that contains the properties of an IMD5 header. These headers precede the data of banner.bin
and icon.bin inside the banner (00000000.app) of a channel, and are used to verify the data of those files.
An IMD5 header is always 32 bytes long.
Attributes
----------
magic : str
Magic number for the header, should be "IMD5".
file_size : int
The size of the file this header precedes.
zeros : int
8 bytes of zero padding.
md5_hash : bytes
The MD5 hash of the file this header precedes.
"""
magic: str # Should always be "IMD5"
file_size: int
zeros: int
md5_hash: bytes

View 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 *

View File

@ -0,0 +1,262 @@
# "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 dataclasses import dataclass as _dataclass
from typing import List
from ..title.ticket import Ticket
from ..title.title import Title
from ..title.tmd import TMD
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)
ticket_dir.joinpath(f"{tid_lower}.tik").write_bytes(title.ticket.dump())
# 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)
content_dir.joinpath("title.tmd").write_bytes(title.tmd.dump())
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()
content_dir.joinpath(f"{content_file_name}.app").write_bytes(
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(content_map_path.read_bytes())
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)
self.shared1_dir.joinpath(f"{content_file_name}.app").write_bytes(
title.get_content_by_index(content_file, skip_hash=skip_hash))
self.shared1_dir.joinpath("content.map").write_bytes(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)
meta_dir.joinpath("title.met").write_bytes(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()
else:
uid_sys.load(uid_sys_path.read_bytes())
uid_sys.add(title.tmd.title_id)
uid_sys_path.write_bytes(uid_sys.dump())
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))
@_dataclass
class InstalledTitles:
"""
An InstalledTitles object that is used to track a title type and any titles that belong to that type that are
installed to an EmuNAND.
Attributes
----------
type : str
The type (Title ID high) of the installed titles.
titles : List[str]
The Title ID low of each installed title.
"""
type: str
titles: List[str]
def get_installed_titles(self) -> List[InstalledTitles]:
"""
Scans for installed titles and returns a list of InstalledTitles objects, which each contain a title type
(Title ID high) and a list of Title ID lows that are installed under it.
Returns
-------
List[InstalledTitles]
The titles installed to the EmuNAND.
"""
# Scan for TID highs present.
tid_highs = [d for d in self.title_dir.iterdir() if d.is_dir()]
# Iterate through each one, verify that every TID low directory contains a TMD, and then add it to the list.
installed_titles = []
for high in tid_highs:
tid_lows = [d for d in high.iterdir() if d.is_dir()]
valid_lows = []
for low in tid_lows:
if low.joinpath("content", "title.tmd").exists():
valid_lows.append(low.name.upper())
installed_titles.append(self.InstalledTitles(high.name.upper(), valid_lows))
return installed_titles
def get_title_tmd(self, tid: str) -> TMD:
"""
Gets the TMD for a title installed to the EmuNAND, and returns it as a TMD objects. Returns an error if the
TMD for the specified Title ID does not exist.
Parameters
----------
tid : str
The Title ID of the Title to get the TMD for.
Returns
-------
TMD
The TMD for the Title.
"""
# Validate the TID, then build a path to the TMD file to verify that it exists.
if len(tid) != 16:
raise ValueError(f"Title ID \"{tid}\" is not a valid!")
tid_high = tid[:8].lower()
tid_low = tid[8:].lower()
tmd_path = self.title_dir.joinpath(tid_high, tid_low, "content", "title.tmd")
if not tmd_path.exists():
raise FileNotFoundError(f"Title with Title ID {tid} does not appear to be installed!")
tmd = TMD()
tmd.load(tmd_path.read_bytes())
return tmd
def get_title_ticket(self, tid: str) -> Ticket:
"""
Gets the Ticket for a title installed to the EmuNAND, and returns it as a Ticket object. Returns an error if
the Ticket for the specified Title ID does not exist.
Parameters
----------
tid : str
The Title ID of the Title to get the Ticket for.
Returns
-------
Ticket
The Ticket for the Title.
"""
# Validate the TID, then build a path to the Ticket files to verify that it exists.
if len(tid) != 16:
raise ValueError(f"Title ID \"{tid}\" is not a valid!")
tid_high = tid[:8].lower()
tid_low = tid[8:].lower()
ticket_path = self.ticket_dir.joinpath(tid_high, f"{tid_low}.tik")
if not ticket_path.exists():
raise FileNotFoundError(f"No Ticket exists for the title with Title ID {tid}!")
ticket = Ticket()
ticket.load(ticket_path.read_bytes())
return ticket

View 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

130
src/libWiiPy/nand/sys.py Normal file
View File

@ -0,0 +1,130 @@
# "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. The new entry will only
be added if the provided Title ID doesn't already have an assigned UID.
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.")
# Ensure this TID hasn't already been assigned a UID. If it has, just exit early and return the UID.
if self.uid_entries.count != 0:
for entry in self.uid_entries:
if entry.title_id == title_id_converted:
return entry.uid
# Generate the new UID by incrementing the current highest UID by 1.
try:
new_uid = self.uid_entries[-1].uid + 1
except IndexError:
new_uid = 4096
self.uid_entries.append(_UidSysEntry(title_id_converted, new_uid))
return new_uid
def create(self) -> None:
"""
Creates a new uid.sys file and initializes it with the standard first entry of 1-2 with UID 4096. This allows
for setting up a uid.sys file without having to load an existing one.
"""
if len(self.uid_entries) != 0:
raise Exception("A uid.sys file appears to already exist!")
self.add("0000000100000002")

View File

@ -1,206 +0,0 @@
# "nus.py" from libWiiPy by NinjaCheetah & Contributors
# https://github.com/NinjaCheetah/libWiiPy
#
# See https://wiibrew.org/wiki/NUS for details about the NUS
import io
import requests
import hashlib
from typing import List
from .title import Title
from .tmd import TMD
from .ticket import Ticket
def download_title(title_id: str, title_version: int = None) -> Title:
"""
Download an entire title and all of its contents, then load the downloaded components into a Title object for
further use. This method is NOT recommended for general use, as it has absolutely no verbosity. It is instead
recommended to call the individual download methods instead to provide more flexibility and output.
Parameters
----------
title_id : str
The Title ID of the title to download.
title_version : int, option
The version of the title to download. Defaults to latest if not set.
Returns
-------
Title
A Title object containing all the data from the downloaded title.
"""
# First, create the new title.
title = Title()
# Download and load the TMD, Ticket, and certs.
title.load_tmd(download_tmd(title_id, title_version))
title.load_ticket(download_ticket(title_id))
title.wad.set_cert_data(download_cert())
# Download all contents
title.load_content_records()
title.content.content_list = download_contents(title_id, title.tmd)
# Return the completed title.
return title
def download_tmd(title_id: str, title_version: int = None) -> bytes:
"""
Downloads the TMD of the Title specified in the object. Will download the latest version by default, or another
version if it was manually specified in the object.
Parameters
----------
title_id : str
The Title ID of the title to download the TMD for.
title_version : int, option
The version of the TMD to download. Defaults to latest if not set.
Returns
-------
bytes
The TMD file from the NUS.
"""
# Build the download URL. The structure is download/<TID>/tmd for latest and download/<TID>/tmd.<version> for
# when a specific version is requested.
tmd_url = "http://nus.cdn.shop.wii.com/ccs/download/" + title_id + "/tmd"
# Add the version to the URL if one was specified.
if title_version is not None:
tmd_url += "." + str(title_version)
# Make the request.
tmd_request = requests.get(url=tmd_url, headers={'User-Agent': 'wii libnup/1.0'}, stream=True)
# Handle a 404 if the TID/version doesn't exist.
if tmd_request.status_code != 200:
raise ValueError("The requested Title ID or TMD version does not exist. Please check the Title ID and Title"
" version and then try again.")
# Save the raw TMD.
raw_tmd = tmd_request.content
# Use a TMD object to load the data and then return only the actual TMD.
tmd_temp = TMD()
tmd_temp.load(raw_tmd)
tmd = tmd_temp.dump()
return tmd
def download_ticket(title_id: str) -> bytes:
"""
Downloads the Ticket of the Title specified in the object. This will only work if the Title ID specified is for
a free title.
Parameters
----------
title_id : str
The Title ID of the title to download the Ticket for.
Returns
-------
bytes
The Ticket file from the NUS.
"""
# Build the download URL. The structure is download/<TID>/cetk, and cetk will only exist if this is a free
# title.
ticket_url = "http://nus.cdn.shop.wii.com/ccs/download/" + 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:
raise ValueError("The requested Title ID does not exist, or refers to a non-free title. Tickets can only"
" be downloaded for titles that are free on the NUS.")
# Save the raw cetk file.
cetk = ticket_request.content
# Use a Ticket object to load only the Ticket data from cetk and return it.
ticket_temp = Ticket()
ticket_temp.load(cetk)
ticket = ticket_temp.dump()
return ticket
def download_cert() -> bytes:
"""
Downloads the signing certificate used by all WADs. This uses System Menu 4.3U as the source.
Returns
-------
bytes
The cert file.
"""
# Download the TMD and cetk for the System Menu 4.3U.
tmd = requests.get(url='http://nus.cdn.shop.wii.com/ccs/download/0000000100000002/tmd.513',
headers={'User-Agent': 'wii libnup/1.0'}, stream=True).content
cetk = requests.get(url='http://nus.cdn.shop.wii.com/ccs/download/0000000100000002/cetk',
headers={'User-Agent': 'wii libnup/1.0'}, stream=True).content
# Assemble the certificate.
with io.BytesIO() as cert_data:
# Certificate Authority data.
cert_data.write(cetk[0x2A4 + 768:])
# Certificate Policy data.
cert_data.write(tmd[0x328:0x328 + 768])
# XS data.
cert_data.write(cetk[0x2A4:0x2A4 + 768])
cert_data.seek(0x0)
cert = cert_data.read()
# Since the cert is always the same, check the hash to make sure nothing went wildly wrong.
if hashlib.sha1(cert).hexdigest() != "ace0f15d2a851c383fe4657afc3840d6ffe30ad0":
raise Exception("An unknown error has occurred downloading and creating the certificate.")
return cert
def download_content(title_id: str, content_id: int) -> bytes:
"""
Downloads a specified content for the title specified in the object.
Parameters
----------
title_id : str
The Title ID of the title to download content from.
content_id : int
The Content ID of the content you wish to download.
Returns
-------
bytes
The downloaded content.
"""
# Build the download URL. The structure is download/<TID>/<Content ID>.
content_id_hex = hex(content_id)[2:]
if len(content_id_hex) < 2:
content_id_hex = "0" + content_id_hex
content_url = "http://nus.cdn.shop.wii.com/ccs/download/" + 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:
raise ValueError("The requested Title ID does not exist, or an invalid Content ID is present in the"
" content records provided.\n Failed while downloading Content ID: 000000" +
content_id_hex)
content_data = content_request.content
return content_data
def download_contents(title_id: str, tmd: TMD) -> List[bytes]:
"""
Downloads all the contents for the title specified in the object. This requires a TMD to already be available
so that the content records can be accessed.
Parameters
----------
title_id : str
The Title ID of the title to download content from.
tmd : TMD
The TMD that matches the title that the contents being downloaded are from.
Returns
-------
List[bytes]
A list of all the downloaded contents.
"""
# Retrieve the content records from the TMD.
content_records = tmd.content_records
# Create a list of Content IDs to download.
content_ids = []
for content_record in content_records:
content_ids.append(content_record.content_id)
# Iterate over that list and download each content in it, then add it to the array of contents.
content_list = []
for content_id in content_ids:
# Call self.download_content() for each Content ID.
content = download_content(title_id, content_id)
content_list.append(content)
return content_list

View File

@ -4,9 +4,10 @@
# This file defines general functions that may be useful in other modules of libWiiPy. Putting them here cuts down on
# clutter in other files.
def align_value(value, alignment=64) -> int:
def _align_value(value, alignment=64) -> int:
"""
Aligns the provided value to the set alignment (defaults to 64).
Aligns the provided value to the set alignment (defaults to 64). Private function used by other libWiiPy modules.
Parameters
----------
@ -26,22 +27,89 @@ def align_value(value, alignment=64) -> int:
return value
def pad_bytes_stream(data, alignment=64) -> bytes:
def _pad_bytes(data, alignment=64) -> bytes:
"""
Pads the provided bytes stream to the provided alignment (defaults to 64).
Pads the provided bytes object to the provided alignment (defaults to 64). Private function used by other libWiiPy
modules.
Parameters
----------
data : BytesIO
data : bytes
The data to align.
alignment : int
The number to align to. Defaults to 64.
Returns
-------
BytesIO
bytes
The aligned data.
"""
while (data.getbuffer().nbytes % alignment) != 0:
data.write(b'\x00')
while (len(data) % alignment) != 0:
data += b'\x00'
return data
def _bitmask(x: int) -> int:
return 1 << x
_wii_menu_versions = {
"Prelaunch": [0, 1, 2],
"1.0J": 64,
"1.0U": 33,
"1.0E": 34,
"2.0J": 128,
"2.0U": 97,
"2.0E": 130,
"2.1E": 162,
"2.2J": 192,
"2.2U": 193,
"2.2E": 194,
"3.0J": 224,
"3.0U": 225,
"3.0E": 226,
"3.1J": 256,
"3.1U": 257,
"3.1E": 258,
"3.2J": 288,
"3.2U": 289,
"3.2E": 290,
"3.3J": 352,
"3.3U": 353,
"3.3E": 354,
"3.3K": 326,
"3.4J": 384,
"3.4U": 385,
"3.4E": 386,
"3.5K": 390,
"4.0J": 416,
"4.0U": 417,
"4.0E": 418,
"4.1J": 448,
"4.1U": 449,
"4.1E": 450,
"4.1K": 454,
"4.2J": 480,
"4.2U": 481,
"4.2E": 482,
"4.2K": 486,
"4.3J": 512,
"4.3U": 513,
"4.3E": 514,
"4.3K": 518,
"4.3U-Mini": 4609,
"4.3E-Mini": 4610
}
_vwii_menu_versions = {
"vWii-1.0.0J": 512,
"vWii-1.0.0U": 513,
"vWii-1.0.0E": 514,
"vWii-4.0.0J": 544,
"vWii-4.0.0U": 545,
"vWii-4.0.0E": 546,
"vWii-5.2.0J": 608,
"vWii-5.2.0U": 609,
"vWii-5.2.0E": 610,
}

View File

@ -1,270 +0,0 @@
# "ticket.py" from libWiiPy by NinjaCheetah & Contributors
# https://github.com/NinjaCheetah/libWiiPy
#
# See https://wiibrew.org/wiki/Ticket for details about the ticket format
import io
import binascii
from .crypto import decrypt_title_key
from .types import TitleLimit
from typing import List
class Ticket:
"""
A Ticket object that allows for either loading and editing an existing Ticket or creating one manually if desired.
Attributes
----------
signature : bytes
The signature applied to the ticket.
ticket_version : int
The version of the ticket.
title_key_enc : bytes
The Title Key contained in the ticket, in encrypted form.
ticket_id : bytes
The unique ID of this ticket, used for console-specific title installations.
console_id : int
The unique ID of the console this ticket was designed for, if this is a console-specific ticket.
title_version : int
The version of the title this ticket was designed for.
common_key_index : int
The index of the common key required to decrypt this ticket's Title Key.
"""
def __init__(self):
# Signature blob header
self.signature_type: bytes = b'' # Type of signature, always 0x10001 for RSA-2048
self.signature: bytes = b'' # Actual signature data
# v0 ticket data
self.signature_issuer: str = "" # Who issued the signature for the ticket
self.ecdh_data: bytes = b'' # Involved in created one-time keys for console-specific title installs.
self.ticket_version: int = 0 # The version of the current ticket file.
self.title_key_enc: bytes = b'' # The title key of the ticket's respective title, encrypted by a common key.
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."
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.
# v1 ticket data
# TODO: Write in v1 ticket attributes here. This code can currently only handle v0 tickets, and will reject v1.
def load(self, ticket: bytes) -> None:
"""
Loads raw Ticket data and sets all attributes of the WAD object. This allows for manipulating an already
existing Ticket.
Parameters
----------
ticket : bytes
The data for the Ticket you wish to load.
"""
with io.BytesIO(ticket) as ticket_data:
# ====================================================================================
# Parses each of the keys contained in the Ticket.
# ====================================================================================
# Signature type.
ticket_data.seek(0x0)
self.signature_type = ticket_data.read(4)
# Signature data.
ticket_data.seek(0x04)
self.signature = ticket_data.read(256)
# Signature issuer.
ticket_data.seek(0x140)
self.signature_issuer = str(ticket_data.read(64).decode())
# ECDH data.
ticket_data.seek(0x180)
self.ecdh_data = ticket_data.read(60)
# Ticket version.
ticket_data.seek(0x1BC)
self.ticket_version = int.from_bytes(ticket_data.read(1))
if self.ticket_version == 1:
raise ValueError("This appears to be a v1 ticket, which is not currently supported by libWiiPy. This "
"feature is planned for a later release. Only v0 tickets are supported at this time.")
# Title Key (Encrypted by a common key).
ticket_data.seek(0x1BF)
self.title_key_enc = ticket_data.read(16)
# Ticket ID.
ticket_data.seek(0x1D0)
self.ticket_id = ticket_data.read(8)
# Console ID.
ticket_data.seek(0x1D8)
self.console_id = int.from_bytes(ticket_data.read(4))
# Title ID.
ticket_data.seek(0x1DC)
self.title_id = ticket_data.read(8)
# Title ID (as a string).
title_id_hex = binascii.hexlify(self.title_id)
self.title_id_str = str(title_id_hex.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
# Permitted titles mask.
ticket_data.seek(0x1E8)
self.permitted_titles = ticket_data.read(4)
# Permit mask.
ticket_data.seek(0x1EC)
self.permit_mask = ticket_data.read(4)
# Whether title export with a PRNG key is allowed.
ticket_data.seek(0x1F0)
self.title_export_allowed = int.from_bytes(ticket_data.read(1))
# Common key index.
ticket_data.seek(0x1F1)
self.common_key_index = int.from_bytes(ticket_data.read(1))
# Unknown data 2.
ticket_data.seek(0x1F2)
self.unknown2 = ticket_data.read(48)
# Content access permissions.
ticket_data.seek(0x222)
self.content_access_permissions = ticket_data.read(64)
# Content limits.
ticket_data.seek(0x264)
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))
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.
Returns
-------
bytes
The full Ticket file as bytes.
"""
# Open the stream and begin writing to it.
with io.BytesIO() as ticket_data:
# Signature type.
ticket_data.write(self.signature_type)
# Signature data.
ticket_data.write(self.signature)
# Padding to 64 bytes.
ticket_data.write(b'\x00' * 60)
# Signature issuer.
ticket_data.write(str.encode(self.signature_issuer))
# ECDH data.
ticket_data.write(self.ecdh_data)
# Ticket version.
ticket_data.write(int.to_bytes(self.ticket_version, 1))
# Reserved (all \0x00).
ticket_data.write(b'\x00\x00')
# Title Key.
ticket_data.write(self.title_key_enc)
# Unknown (write \0x00).
ticket_data.write(b'\x00')
# Ticket ID.
ticket_data.write(self.ticket_id)
# Console ID.
ticket_data.write(int.to_bytes(self.console_id, 4))
# Title ID.
ticket_data.write(self.title_id)
# Unknown data 1.
ticket_data.write(self.unknown1)
# Title version.
title_version_high = round(self.title_version / 256)
ticket_data.write(int.to_bytes(title_version_high, 1))
title_version_low = self.title_version % 256
ticket_data.write(int.to_bytes(title_version_low, 1))
# Permitted titles mask.
ticket_data.write(self.permitted_titles)
# Permit mask.
ticket_data.write(self.permit_mask)
# Title Export allowed.
ticket_data.write(int.to_bytes(self.title_export_allowed, 1))
# Common Key index.
ticket_data.write(int.to_bytes(self.common_key_index, 1))
# Unknown data 2.
ticket_data.write(self.unknown2)
# Content access permissions.
ticket_data.write(self.content_access_permissions)
# Padding (always \x00).
ticket_data.write(b'\x00\x00')
# Iterate over Title Limit objects, write them back into raw data, then add them to the Ticket.
for title_limit in range(len(self.title_limits_list)):
title_limit_data = io.BytesIO()
# Write all fields from the title limit entry.
title_limit_data.write(int.to_bytes(self.title_limits_list[title_limit].limit_type, 4))
title_limit_data.write(int.to_bytes(self.title_limits_list[title_limit].maximum_usage, 4))
# Seek to the start and write the entry to the Ticket.
title_limit_data.seek(0x0)
ticket_data.write(title_limit_data.read())
title_limit_data.close()
# Set the Ticket attribute of the object to the new raw Ticket.
ticket_data.seek(0x0)
ticket_data_raw = ticket_data.read()
# Return the raw TMD for the data contained in the object.
return ticket_data_raw
def get_title_id(self) -> str:
"""
Gets the Title ID of the ticket's associated title.
Returns
-------
str
The Title ID of the title.
"""
title_id_str = str(self.title_id.decode())
return title_id_str
def get_common_key_type(self) -> str:
"""
Gets the name of the common key used to encrypt the Title Key contained in the ticket.
Returns
-------
str
The name of the common key required.
See Also
--------
commonkeys.get_common_key
"""
match self.common_key_index:
case 0:
return "Common"
case 1:
return "Korean"
case 2:
return "vWii"
def get_title_key(self) -> bytes:
"""
Gets the decrypted title key contained in the ticket.
Returns
-------
bytes
The decrypted title key.
"""
title_key = decrypt_title_key(self.title_key_enc, self.common_key_index, self.title_id)
return title_key
def set_title_id(self, title_id) -> None:
"""
Sets the Title ID of the title in the Ticket.
Parameters
----------
title_id : str
The new Title ID of the title.
"""
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)

View File

@ -1,237 +0,0 @@
# "title.py" from libWiiPy by NinjaCheetah & Contributors
# https://github.com/NinjaCheetah/libWiiPy
#
# See https://wiibrew.org/wiki/Title for details about how titles are formatted
from .content import ContentRegion
from .ticket import Ticket
from .tmd import TMD
from .wad import WAD
class Title:
"""
A Title object that contains all components of a title, and allows altering them. Provides higher-level access
than manually creating WAD, TMD, Ticket, and ContentRegion objects and ensures that any data that needs to match
between files matches.
Attributes
----------
wad : WAD
A WAD object of a WAD containing the title's data.
tmd : TMD
A TMD object of the title's TMD.
ticket : Ticket
A Ticket object of the title's Ticket.
content: ContentRegion
A ContentRegion object containing the title's contents.
"""
def __init__(self):
self.wad: WAD = WAD()
self.tmd: TMD = TMD()
self.ticket: Ticket = Ticket()
self.content: ContentRegion = ContentRegion()
def load_wad(self, wad: bytes) -> None:
"""
Load existing WAD data into the title and create WAD, TMD, Ticket, and ContentRegion objects based off of it
to allow you to modify that data. Note that this will overwrite any existing data for this title.
Parameters
----------
wad : bytes
The data for the WAD you wish to load.
"""
# Create a new WAD object based on the WAD data provided.
self.wad = WAD()
self.wad.load(wad)
# Load the TMD.
self.tmd = TMD()
self.tmd.load(self.wad.get_tmd_data())
# Load the ticket.
self.ticket = Ticket()
self.ticket.load(self.wad.get_ticket_data())
# Load the content.
self.content = ContentRegion()
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:
raise ValueError("The Title IDs of the TMD and Ticket in this WAD do not match. This WAD appears to be "
"invalid.")
def dump_wad(self) -> bytes:
"""
Dumps all title components (TMD, Ticket, and contents) back into the WAD object, and then dumps the WAD back
into raw data and returns it.
Returns
-------
wad_data : bytes
The raw data of the WAD.
"""
# Set WAD type to ib if the title being packed is boot2.
if self.tmd.title_id == "0000000100000001":
self.wad.wad_type = "ib"
# Dump the TMD and set it in the WAD.
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())
# Dump the WAD with the new regions back into raw data and return it.
wad_data = self.wad.dump()
return wad_data
def load_tmd(self, tmd: bytes) -> None:
"""
Load existing TMD data into the title. Note that this will overwrite any existing TMD data for this title.
Parameters
----------
tmd : bytes
The data for the WAD you wish to load.
"""
# Load TMD.
self.tmd.load(tmd)
def load_ticket(self, ticket: bytes) -> None:
"""
Load existing Ticket data into the title. Note that this will overwrite any existing Ticket data for this
title.
Parameters
----------
ticket : bytes
The data for the WAD you wish to load.
"""
# Load Ticket.
self.ticket.load(ticket)
def load_content_records(self) -> None:
"""
Load content records from the TMD into the ContentRegion to allow loading content files based on the records.
This requires that a TMD has already been loaded and will throw an exception if it isn't.
"""
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.
self.content.content_records = self.tmd.content_records
def set_title_id(self, title_id: str) -> None:
"""
Sets the Title ID of the title in both the TMD and Ticket.
Parameters
----------
title_id : str
The new Title ID of the 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)
self.ticket.set_title_id(title_id)
def get_content_by_index(self, index: id) -> bytes:
"""
Gets an individual content from the content region based on the provided index, in decrypted form.
Parameters
----------
index : int
The index of the content you want to get.
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)
return dec_content
def get_content_by_cid(self, cid: int) -> 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.
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)
return dec_content
def set_enc_content(self, enc_content: bytes, cid: int, index: int, content_type: int, content_size: int,
content_hash: bytes) -> None:
"""
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.
Parameters
----------
enc_content : bytes
The new encrypted content to set.
cid : int
The Content ID to assign the new content in the content record.
index : int
The index to place the new content at.
content_type : int
The type of the new content.
content_size : int
The size of the new encrypted content when decrypted.
content_hash : bytes
The hash of the new encrypted content when decrypted.
"""
# Set the encrypted content.
self.content.set_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:
"""
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.
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.
"""
# Set the decrypted content.
self.content.set_content(dec_content, cid, index, content_type, self.ticket.get_title_key())
# 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.
Parameters
----------
dec_content : bytes
The decrypted content to load.
index : int
The content index to load the content at.
"""
# Load the decrypted content.
self.content.load_content(dec_content, index, self.ticket.get_title_key())

View File

@ -0,0 +1,13 @@
# "title/__init__.py" from libWiiPy by NinjaCheetah & Contributors
# https://github.com/NinjaCheetah/libWiiPy
from .cert import *
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 *

362
src/libWiiPy/title/cert.py Normal file
View File

@ -0,0 +1,362 @@
# "title/cert.py" from libWiiPy by NinjaCheetah & Contributors
# https://github.com/NinjaCheetah/libWiiPy
#
# See https://wiibrew.org/wiki/Certificate_chain for details about the Wii's certificate chain
import io
from enum import IntEnum as _IntEnum
from ..shared import _align_value, _pad_bytes
from .ticket import Ticket
from .tmd import TMD
from Crypto.Hash import SHA1
from Crypto.PublicKey import RSA
from Crypto.Signature import pkcs1_15
class CertificateType(_IntEnum):
RSA_4096 = 0x00010000
RSA_2048 = 0x00010001
ECC = 0x00010002
class CertificateSignatureLength(_IntEnum):
RSA_4096 = 0x200
RSA_2048 = 0x100
ECC = 0x3C
class CertificateKeyType(_IntEnum):
RSA_4096 = 0x00000000
RSA_2048 = 0x00000001
ECC = 0x00000002
class CertificateKeyLength(_IntEnum):
RSA_4096 = 0x200
RSA_2048 = 0x100
ECC = 0x3C
class Certificate:
"""
A Certificate object used to parse a certificate used for the Wii's content verification.
Attributes
----------
type: CertificateType
The type of the certificate, either RSA-2048, RSA-4096, or ECC.
signature: bytes
The signature data of the certificate.
issuer: str
The certificate that issued this certificate.
pub_key_type: CertificateKeyType
The type of public key contained in the certificate, either RSA-2048, RSA-4096, or ECC.
child_name: str
The name of this certificate.
pub_key_id: int
The ID of this certificate's public key.
pub_key_modulus: int
The modulus of this certificate's public key. Combined with the exponent to get the full key.
pub_key_exponent: int
The exponent of this certificate's public key. Combined with the modulus to get the full key.
"""
def __init__(self):
self.type: CertificateType | None = None
self.signature: bytes = b''
self.issuer: str = ""
self.pub_key_type: CertificateKeyType | None = None
self.child_name: str = ""
self.pub_key_id: int = 0
self.pub_key_modulus: int = 0
self.pub_key_exponent: int = 0
def load(self, cert: bytes) -> None:
"""
Loads certificate data into the Certificate object, allowing you to parse the certificate.
Parameters
----------
cert: bytes
The data for the certificate to load.
"""
with io.BytesIO(cert) as cert_data:
# Read the first 4 bytes of the cert to get the certificate's type.
try:
self.type = CertificateType.from_bytes(cert_data.read(0x4))
except ValueError:
raise ValueError("Invalid Certificate Type!")
cert_length = CertificateSignatureLength[self.type.name]
self.signature = cert_data.read(cert_length.value)
cert_data.seek(0x40 + cert_length.value)
self.issuer = str(cert_data.read(0x40).replace(b'\x00', b'').decode())
try:
cert_data.seek(0x80 + cert_length.value)
self.pub_key_type = CertificateKeyType.from_bytes(cert_data.read(0x4))
except ValueError:
raise ValueError("Invalid Certificate Key type!")
cert_data.seek(0x84 + cert_length.value)
self.child_name = str(cert_data.read(0x40).replace(b'\x00', b'').decode())
cert_data.seek(0xC4 + cert_length.value)
self.pub_key_id = int.from_bytes(cert_data.read(0x4))
key_length = CertificateKeyLength[self.pub_key_type.name]
cert_data.seek(0xC8 + cert_length.value)
self.pub_key_modulus = int.from_bytes(cert_data.read(key_length.value))
if self.pub_key_type == CertificateKeyType.RSA_4096 or self.pub_key_type == CertificateKeyType.RSA_2048:
self.pub_key_exponent = int.from_bytes(cert_data.read(0x4))
def dump(self) -> bytes:
"""
Dump the certificate object back into bytes.
Returns
-------
bytes:
The certificate file as bytes.
"""
cert_data = b''
cert_data += int.to_bytes(self.type.value, 4)
cert_data += self.signature
cert_data = _pad_bytes(cert_data)
# Pad out the issuer name with null bytes.
issuer = self.issuer.encode()
while len(issuer) < 0x40:
issuer += b'\x00'
cert_data += issuer
cert_data += int.to_bytes(self.pub_key_type.value, 4)
# Pad out the child cert name with null bytes
child_name = self.child_name.encode()
while len(child_name) < 0x40:
child_name += b'\x00'
cert_data += child_name
cert_data += int.to_bytes(self.pub_key_id, 4)
cert_data += int.to_bytes(self.pub_key_modulus, CertificateKeyLength[self.pub_key_type.name])
if self.pub_key_type == CertificateKeyType.RSA_4096 or self.pub_key_type == CertificateKeyType.RSA_2048:
cert_data += int.to_bytes(self.pub_key_exponent, 4)
# Pad out the certificate data to a multiple of 64.
cert_data = _pad_bytes(cert_data)
return cert_data
class CertificateChain:
"""
A CertificateChain object used to parse the chain of certificates stored in a WAD that are used for the Wii's
content verification. The certificate chain is the format that the certificates are stored in as part of every WAD.
Attributes
----------
ca_cert: Certificate
The CA certificate from the chain.
tmd_cert: Certificate
The CP (TMD) certificate from the chain.
ticket_cert: Certificate
The XS (Ticket) certificate from the chain.
"""
def __init__(self):
self.ca_cert: Certificate = Certificate()
self.tmd_cert: Certificate = Certificate()
self.ticket_cert: Certificate = Certificate()
def load(self, cert_chain: bytes) -> None:
"""
Loads certificate chain data into the CertificateChain object, allowing you to parse the individual
certificates stored in the chain.
Parameters
----------
cert_chain: bytes
The data for the certificate chain to load.
"""
with (io.BytesIO(cert_chain) as cert_chain_data):
# Read the two fields that denote different length sections of the certificate, so that we know how long
# this certificate is in total.
offset = 0x0
for _ in range(3):
cert_chain_data.seek(offset)
cert_type = CertificateType.from_bytes(cert_chain_data.read(0x4))
cert_chain_data.seek(offset + 0x80 + CertificateSignatureLength[cert_type.name].value)
key_type = CertificateKeyType.from_bytes(cert_chain_data.read(0x4))
cert_size = _align_value(0xC8 + CertificateSignatureLength[cert_type.name].value +
CertificateKeyLength[key_type.name].value)
cert_chain_data.seek(offset + 0x0)
cert = Certificate()
cert.load(cert_chain_data.read(cert_size))
if cert.issuer == "Root":
self.ca_cert = cert
elif cert.issuer.find("Root-CA") != -1:
if cert.child_name.find("CP") != -1:
self.tmd_cert = cert
elif cert.child_name.find("XS") != -1:
self.ticket_cert = cert
else:
raise ValueError("Unknown certificate in chain!")
else:
raise ValueError("Unknown certificate in chain!")
offset += cert_size
def dump(self) -> bytes:
"""
Dumps the full certificate chain back into bytes. This chain will always be formatted with the CA cert first,
followed by the CP (TMD) cert, then finally the XS (Ticket) cert.
Returns
-------
bytes
The full certificate chain as bytes.
"""
cert_chain_data = b''
cert_chain_data += self.ca_cert.dump()
cert_chain_data += self.tmd_cert.dump()
cert_chain_data += self.ticket_cert.dump()
return cert_chain_data
def verify_ca_cert(ca_cert: Certificate) -> bool:
"""
Verify a Wii CA certificate using the root public key. The retail or development root key will be automatically
selected based off of the name of the CA certificate provided.
Parameters
----------
ca_cert: Certificate
The CA certificate to verify.
Returns
-------
bool
Whether the certificate is valid or not.
"""
if ca_cert.issuer != "Root" or ca_cert.child_name.find("CA") == -1:
raise ValueError("The provided certificate is not a CA certificate!")
if ca_cert.child_name == "CA00000001":
root_key_modulus = \
(b'\xf8$lX\xba\xe7P\x03\x01\xfb\xb7\xc2\xeb\xe0\x01\x05q\xda\x92#x\xf0QN\xc0\x03\x1d\xd0\xd2\x1e\xd3\xd0~'
b'\xfc\x85 i\xb5\xde\x9b\xb9Q\xa8\xbc\x90\xa2D\x92m7\x92\x95\xae\x946\xaa\xa6\xa3\x02Q\x0c{\x1d\xed\xd5'
b'\xfb \x86\x9d\x7f0\x16\xf6\xbee\xd3\x83\xa1m\xb32\x1b\x955\x18\x90\xb1p\x02\x93~\xe1\x93\xf5~\x99\xa2GN'
b'\x9d8$\xc7\xae\xe3\x85A\xf5g\xe7Q\x8cz\x0e8\xe7\xeb\xafA\x19\x1b\xcf\xf1{B\xa6\xb4\xed\xe6\xce\x8d\xe71'
b'\x8f\x7fR\x04\xb3\x99\x0e"gE\xaf\xd4\x85\xb2D\x93\x00\x8b\x08\xc7\xf6\xb7\xe5k\x02\xb3\xe8\xfe\x0c\x9d'
b'\x85\x9c\xb8\xb6\x82#\xb8\xab\'\xee_e8\x07\x8b-\xb9\x1e*\x15>\x85\x81\x80r\xa2;m\xd92\x81\x05Oo\xb0\xf6'
b'\xf5\xad(>\xca\x0bz\xf3TU\xe0=\xa7\xb6\x83&\xf3\xec\x83J\xf3\x14\x04\x8a\xc6\xdf \xd2\x85\x08g<\xabb\xa2'
b'\xc7\xbc\x13\x1aS>\x0bf\x80k\x1c0fK7#1\xbd\xc4\xb0\xca\xd8\xd1\x1e\xe7\xbb\xd9(UH\xaa\xec\x1ff\xe8!\xb3'
b'\xc8\xa0Gi\x00\xc5\xe6\x88\xe8\x0c\xce<a\xd6\x9c\xbb\xa17\xc6`Ozr\xdd\x8c{>=Q)\r\xaajY{\x08\x1f\x9d63'
b'\xa3Fz5a\t\xac\xa7\xdd}./\xb2\xc1\xae\xb8\xe2\x0fH\x92\xd8\xb9\xf8\xb4oN<\x11\xf4\xf4}\x8bu}\xfe\xfe\xa3'
b'\x89\x9c3Y\\^\xfd\xeb\xcb\xab\xe8A>:\x9a\x80<i5n\xb2\xb2\xad\\\xc4\xc8XE^\xf5\xf7\xb3\x06D\xb4|d\x06\x8c'
b'\xdf\x80\x9fv\x02Z-\xb4F\xe0=|\xf6/4\xe7\x02E{\x02\xa4\xcf]\x9d\xd5<\xa5:|\xa6)x\x8cg\xca\x08\xbf\xec'
b'\xcaC\xa9W\xad\x16\xc9N\x1c\xd8u\xca\x10}\xce~\x01\x18\xf0\xdfk\xfe\xe5\x1d\xdb\xd9\x91\xc2n`\xcdHX\xaa'
b'Y,\x82\x00u\xf2\x9fRl\x91|o\xe5@>\xa7\xd4\xa5\x0c\xec;s\x84\xde\x88n\x82\xd2\xebMNB\xb5\xf2\xb1I\xa8\x1e'
b'\xa7\xceqD\xdc)\x94\xcf\xc4N\x1f\x91\xcb\xd4\x95')
elif ca_cert.child_name == "CA00000002":
root_key_modulus = \
(b'\x00\xd0\x1f\xe1\x00\xd45V\xb2KV\xda\xe9q\xb5\xa5\xd3\x84\xb90\x03\xbe\x1b\xbf(\xa20[\x06\x06EF}[\x02Q'
b'\xd2V\x1a\'O\x9e\x9f\x9c\xecdaP\xab=*\xe36hf\xac\xa4\xba\xe8\x1a\xe3\xd7\x9a\xa6\xb0J\x8b\xcb\xa7\xe6'
b'\xfbd\x89E\xeb\xdf\xdb\x85\xba\t\x1f\xd7\xd1\x14\xb5\xa3\xa7\x80\xe3\xa2.n\xcd\x87\xb5\xa4\xc6\xf9\x10'
b'\xe4\x03"\x08\x81K\x0c\xee\xa1\xa1}\xf79i_a~\xf65(\xdb\x94\x967\xa0V\x03\x7f{2A8\x95\xc0\xa8\xf1\x98.'
b'\x15e\xe3\x8e\xed\xc2.Y\x0e\xe2g{\x86\t\xf4\x8c.0?\xbc@\\\xac\x18\x04/\x82 \x84\xe4\x93h\x03\xda\x7fA4'
b'\x92HV+\x8e\xe1/x\xf8\x03$c0\xbc{\xe7\xeerJ\xf4X\xa4r\xe7\xabF\xa1\xa7\xc1\x0c/\x18\xfa\x07\xc3\xdd\xd8'
b'\x98\x06\xa1\x1c\x9c\xc10\xb2G\xa3<\x8dG\xdeg\xf2\x9eUw\xb1\x1cCI=[\xbav4\xa7\xe4\xe7\x151\xb7\xdfY\x81'
b'\xfe$\xa1\x14UL\xbd\x8f\x00\\\xe1\xdb5\x08\\\xcf\xc7x\x06\xb6\xde%@h\xa2l\xb5I-E\x80C\x8f\xe1\xe5\xa9'
b'\xedu\xc5\xedE\x1d\xcex\x949\xcc\xc3\xba(\xa21*\x1b\x87\x19\xef\x0fs\xb7\x13\x95\x0c\x02Y\x1atb\xa6\x07'
b'\xf3|\n\xa7\xa1\x8f\xa9C\xa3mu*_A\x92\xf0\x13a\x00\xaa\x9c\xb4\x1b\xbe\x14\xbe\xb1\xf9\xfci/\xdf\xa0\x94'
b'F\xdeZ\x9d\xde,\xa5\xf6\x8c\x1c\x0c!B\x92\x87\xcb-\xaa\xa3\xd2cu/s\xe0\x9f\xafDy\xd2\x81t)\xf6\x98\x00'
b'\xaf\xdekY-\xc1\x98\x82\xbd\xf5\x81\xcc\xab\xf2\xcb\x91\x02\x9e\xf3\\L\xfd\xbb\xffI\xc1\xfa\x1b/\xe3\x1d'
b'\xe7\xa5`\xec\xb4~\xbc\xfe2B[\x95o\x81\xb6\x99\x17H~;x\x91Q\xdb.x\xb1\xfd.\xbe~bk>\xa1e\xb4\xfb\x00\xcc'
b'\xb7Q\xafPs)\xc4\xa3\x93\x9e\xa6\xdd\x9cP\xa0\xe78k\x01EykA\xafa\xf7\x85U\x94O;\xc2-\xc3\xbd\r\x00\xf8y'
b'\x8aB\xb1\xaa\xa0\x83 e\x9a\xc79Z\xb4\xf3)')
else:
raise ValueError("The provided CA certificate is not valid!")
root_key_exponent = 0x00010001
cert_hash = SHA1.new(ca_cert.dump()[576:])
public_key = RSA.construct((int.from_bytes(root_key_modulus), root_key_exponent))
try:
pkcs1_15.new(public_key).verify(cert_hash, ca_cert.signature)
return True
except ValueError:
return False
def verify_cert_sig(ca_cert: Certificate, target_cert: Certificate) -> bool:
"""
Verify a TMD or Ticket certificate using a CA certificate.
Parameters
----------
ca_cert: Certificate
The CA certificate to use for verification.
target_cert: Certificate
The target certificate to verify.
Returns
-------
bool
Whether the certificate's signature is valid or not.
"""
if ca_cert.issuer != "Root" or ca_cert.child_name.find("CA") == -1:
raise ValueError("The provided certificate is not a CA certificate!")
# The issuer of the TMD/Ticket certs is Root-CA0000000X, so prepend "Root-" to the CA cert child name. If these
# don't match, then there's probably a mismatch between retail and development certs.
if f"Root-{ca_cert.child_name}" != target_cert.issuer:
raise ValueError("The certificate you are trying to verify does not match the provided CA certificate!")
cert_hash = SHA1.new(target_cert.dump()[320:])
public_key = RSA.construct((ca_cert.pub_key_modulus, ca_cert.pub_key_exponent))
try:
pkcs1_15.new(public_key).verify(cert_hash, target_cert.signature)
return True
except ValueError:
return False
def verify_tmd_sig(tmd_cert: Certificate, tmd: TMD) -> bool:
"""
Verify the signature of a TMD file using a TMD certificate.
Parameters
----------
tmd_cert: Certificate
The TMD certificate to use for verification.
tmd: TMD
The TMD to verify.
Returns
-------
bool
Whether the TMD's signature is valid or not.
"""
if tmd_cert.issuer.find("Root-CA") == -1 or tmd_cert.child_name.find("CP") == -1:
raise ValueError("The provided TMD certificate is not valid!")
if f"{tmd_cert.issuer}-{tmd_cert.child_name}" != tmd.signature_issuer:
raise ValueError("The signature you are trying to verify was not created with the provided TMD certificate!")
tmd_hash = SHA1.new(tmd.dump()[320:])
public_key = RSA.construct((tmd_cert.pub_key_modulus, tmd_cert.pub_key_exponent))
try:
pkcs1_15.new(public_key).verify(tmd_hash, tmd.signature)
return True
except ValueError:
return False
def verify_ticket_sig(ticket_cert: Certificate, ticket: Ticket) -> bool:
"""
Verify the signature of a Ticket file using a Ticket certificate.
Parameters
----------
ticket_cert: Certificate
The Ticket certificate to use for verification.
ticket: Ticket
The Ticket to verify.
Returns
-------
bool
Whether the Ticket's signature is valid or not.
"""
if ticket_cert.issuer.find("Root-CA") == -1 or ticket_cert.child_name.find("XS") == -1:
raise ValueError("The provided Ticket certificate is not valid!")
if f"{ticket_cert.issuer}-{ticket_cert.child_name}" != ticket.signature_issuer:
raise ValueError("The signature you are trying to verify was not created with the provided Ticket certificate!")
ticket_hash = SHA1.new(ticket.dump()[320:])
public_key = RSA.construct((ticket_cert.pub_key_modulus, ticket_cert.pub_key_exponent))
try:
pkcs1_15.new(public_key).verify(ticket_hash, ticket.signature)
return True
except ValueError:
return False

View File

@ -1,4 +1,4 @@
# "commonkeys.py" from libWiiPy by NinjaCheetah & Contributors
# "title/commonkeys.py" from libWiiPy by NinjaCheetah & Contributors
# https://github.com/NinjaCheetah/libWiiPy
import binascii
@ -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

View File

@ -0,0 +1,605 @@
# "title/content.py" from libWiiPy by NinjaCheetah & Contributors
# https://github.com/NinjaCheetah/libWiiPy
#
# See https://wiibrew.org/wiki/Title for details about how titles are formatted
import binascii
import io
import hashlib
from typing import List
from dataclasses import dataclass as _dataclass
from 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
region in both encrypted or decrypted form, and setting new content.
Attributes
----------
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_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:
"""
Loads the raw content region and builds a list of all the contents.
Parameters
----------
content_region : bytes
The raw data for the content region being loaded.
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:
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.
# Because of this, we need to add bytes to the next 64 byte offset if the previous content wasn't that long.
for content in self.content_records[:-1]:
start_offset = content.content_size + self.content_start_offsets[-1]
if (content.content_size % 64) != 0:
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(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.
bytes_to_read = self.content_records[content].content_size
if (bytes_to_read % 16) != 0:
bytes_to_read += 16 - (bytes_to_read % 16)
# Read the file based on the size of the content in the associated record, then append that data to
# the list of content.
content_enc = content_region_data.read(bytes_to_read)
self.content_list.append(content_enc)
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
content list matches the raw data.
Returns
-------
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) % 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
# 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:
"""
Gets an individual content from the content region based on the provided index, in encrypted form.
Parameters
----------
index : int
The index of the content you want to get.
Returns
-------
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
def get_enc_content_by_cid(self, cid: int) -> bytes:
"""
Gets an individual content from the content region based on the provided Content ID, in encrypted form.
Parameters
----------
cid : int
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.
"""
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
def get_enc_contents(self) -> List[bytes]:
"""
Gets a list of all encrypted contents from the content region.
Returns
-------
List[bytes]
A list containing all encrypted contents.
"""
return self.content_list
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.
Parameters
----------
index : int
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.
"""
# 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, 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:
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, 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, 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.
"""
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, skip_hash=False) -> List[bytes]:
"""
Gets a list of all contents from the content region, in decrypted form.
Parameters
----------
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
-------
List[bytes]
A list containing all decrypted contents.
"""
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, skip_hash))
return dec_contents
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:
"""
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 add.
cid : int
The Content ID to assign the new content in the content record.
index : int
The index used when encrypting the new content.
content_type : int
The type of the new content.
content_size : int
The size of the new encrypted content when decrypted.
content_hash : bytes
The hash of the new encrypted content when decrypted.
"""
# 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 add_content(self, dec_content: bytes, cid: int, content_type: int, title_key: bytes) -> None:
"""
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.
index : int
The index to place the new content at.
title_key : bytes
The Title Key that matches the new decrypted content.
cid : int
The Content ID to assign the new content in the content record.
content_type : int
The type of the new content.
"""
# Store the size of the new content.
content_size = len(dec_content)
# Calculate the hash of the new content.
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, index, content_size, content_hash, cid, content_type)
def load_enc_content(self, enc_content: bytes, index: int) -> None:
"""
Loads the provided encrypted content into the content region at the specified index, with the assumption that
it matches the record at that index. Not recommended for most use cases, use decrypted content and
load_content() instead.
Parameters
----------
enc_content : bytes
The encrypted content to load.
index : int
The content index to load the content at.
"""
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 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 index to load the content at.
title_key: bytes
The Title Key that matches the decrypted 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.")
# 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.
# 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:
raise TypeError("SHA-1 hash type is not valid! It must be either type str or bytes.")
# Generate the file name for the new shared content by incrementing the highest name by 1. Thank you, Nintendo,
# for not just storing these as integers like you did EVERYWHERE else.
try:
maximum_index = int(self.shared_records[-1].shared_id, 16)
new_index = f"{maximum_index + 1:08X}".lower()
except IndexError:
new_index = f"{0:08X}"
self.shared_records.append(_SharedContentRecord(new_index, content_hash_converted))
return new_index

View File

@ -1,12 +1,36 @@
# "crypto.py" from libWiiPy by NinjaCheetah & Contributors
# "title/crypto.py" from libWiiPy by NinjaCheetah & Contributors
# https://github.com/NinjaCheetah/libWiiPy
import binascii
import struct
from .commonkeys import get_common_key
from Crypto.Cipher import AES
from Crypto.Cipher import AES as _AES
def decrypt_title_key(title_key_enc, common_key_index, title_id) -> bytes:
def _convert_tid_to_iv(title_id: str | bytes) -> 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.
@ -17,9 +41,11 @@ def decrypt_title_key(title_key_enc, common_key_index, title_id) -> bytes:
title_key_enc : bytes
The encrypted Title Key.
common_key_index : int
The index of the common key to be returned.
title_id : bytes
The title ID of the title that the key is for.
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
-------
@ -27,16 +53,53 @@ def decrypt_title_key(title_key_enc, common_key_index, title_id) -> bytes:
The decrypted Title Key.
"""
# Load the correct common key for the title.
common_key = get_common_key(common_key_index)
# Calculate the IV by adding 8 bytes to the end of the Title ID.
title_key_iv = title_id + (b'\x00' * 8)
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)
# 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, dev=False) -> bytes:
"""
Encrypts the provided Title Key with the selected common key.
Requires the index of the common key to use, and the Title ID of the title that the Title Key is for.
Parameters
----------
title_key_dec : bytes
The decrypted Title Key.
common_key_index : int
The index of the common key used to encrypt the Title Key.
title_id : bytes, str
The Title ID of the title that the key is for.
dev : bool
Whether the Title Key is encrypted with the development key or not.
Returns
-------
bytes
An encrypted Title Key.
"""
# Load the correct common key for the title.
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)
# The IV will always be in the same format by this point, so add the last 8 bytes.
title_key_iv = title_key_iv + (b'\x00' * 8)
# Create a new AES object with the values provided.
aes = _AES.new(common_key, _AES.MODE_CBC, title_key_iv)
# Encrypt Title Key using the AES object.
title_key = aes.encrypt(title_key_dec)
return title_key
def decrypt_content(content_enc, title_key, content_index, content_length) -> bytes:
"""
Gets the decrypted version of the encrypted content.
@ -68,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.
@ -107,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.

View File

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

324
src/libWiiPy/title/nus.py Normal file
View File

@ -0,0 +1,324 @@
# "title/nus.py" from libWiiPy by NinjaCheetah & Contributors
# https://github.com/NinjaCheetah/libWiiPy
#
# See https://wiibrew.org/wiki/NUS for details about the NUS
import requests
#import hashlib
from typing import List
#from urllib.parse import urlparse as _urlparse
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/"]
def download_title(title_id: str, title_version: int = None, wiiu_endpoint: bool = False,
endpoint_override: str = None) -> Title:
"""
Download an entire title and all of its contents, then load the downloaded components into a Title object for
further use. This method is NOT recommended for general use, as it has absolutely no verbosity. It is instead
recommended to call the individual download methods instead to provide more flexibility and output.
Parameters
----------
title_id : str
The Title ID of the title to download.
title_version : int, optional
The version of the title to download. Defaults to latest if not set.
wiiu_endpoint : bool, optional
Whether the Wii U endpoint for the NUS should be used or not. This increases download speeds. Defaults to False.
endpoint_override: str, optional
A custom endpoint URL to use instead of the standard Wii or Wii U endpoints. Defaults to no override, and if
set entirely overrides the "wiiu_endpoint" parameter.
Returns
-------
Title
A Title object containing all the data from the downloaded title.
"""
# First, create the new title.
title = Title()
# Download and load the certificate chain, TMD, and Ticket.
title.load_cert_chain(download_cert_chain(wiiu_endpoint, endpoint_override))
title.load_tmd(download_tmd(title_id, title_version, wiiu_endpoint, endpoint_override))
title.load_ticket(download_ticket(title_id, wiiu_endpoint, endpoint_override))
# Download all contents
title.load_content_records()
title.content.content_list = download_contents(title_id, title.tmd, wiiu_endpoint, endpoint_override)
# Return the completed title.
return title
def download_tmd(title_id: str, title_version: int = None, wiiu_endpoint: bool = False,
endpoint_override: str = None) -> bytes:
"""
Downloads the TMD of the Title specified in the object. Will download the latest version by default, or another
version if it was manually specified in the object.
Parameters
----------
title_id : str
The Title ID of the title to download the TMD for.
title_version : int, option
The version of the TMD to download. Defaults to latest if not set.
wiiu_endpoint : bool, option
Whether the Wii U endpoint for the NUS should be used or not. This increases download speeds. Defaults to False.
endpoint_override: str, optional
A custom endpoint URL to use instead of the standard Wii or Wii U endpoints. Defaults to no override, and if
set entirely overrides the "wiiu_endpoint" parameter.
Returns
-------
bytes
The TMD file from the NUS.
"""
# 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 endpoint_override is not None:
endpoint_url = _validate_endpoint(endpoint_override)
else:
if wiiu_endpoint:
endpoint_url = _nus_endpoint[1]
else:
endpoint_url = _nus_endpoint[0]
tmd_url = endpoint_url + title_id + "/tmd"
# Add the version to the URL if one was specified.
if title_version is not None:
tmd_url += "." + str(title_version)
# Make the request.
try:
tmd_request = requests.get(url=tmd_url, headers={'User-Agent': 'wii libnup/1.0'}, stream=True)
except requests.exceptions.ConnectionError:
if endpoint_override:
raise ValueError("A connection could not be made to the NUS endpoint. Please make sure that your endpoint "
"override is valid.")
else:
raise Exception("A connection could not be made to the NUS endpoint. The NUS may be unavailable.")
# Handle a 404 if the TID/version doesn't exist.
if tmd_request.status_code != 200:
raise ValueError("The requested Title ID or TMD version does not exist. Please check the Title ID and Title"
" version and then try again.")
# Save the raw TMD.
raw_tmd = tmd_request.content
# Use a TMD object to load the data and then return only the actual TMD.
tmd_temp = TMD()
tmd_temp.load(raw_tmd)
tmd = tmd_temp.dump()
return tmd
def download_ticket(title_id: str, wiiu_endpoint: bool = False, endpoint_override: str = None) -> bytes:
"""
Downloads the Ticket of the Title specified in the object. This will only work if the Title ID specified is for
a free title.
Parameters
----------
title_id : str
The Title ID of the title to download the Ticket for.
wiiu_endpoint : bool, option
Whether the Wii U endpoint for the NUS should be used or not. This increases download speeds. Defaults to False.
endpoint_override: str, optional
A custom endpoint URL to use instead of the standard Wii or Wii U endpoints. Defaults to no override, and if
set entirely overrides the "wiiu_endpoint" parameter.
Returns
-------
bytes
The Ticket file from the NUS.
"""
# Build the download URL. The structure is download/<TID>/cetk, and cetk will only exist if this is a free
# title.
if endpoint_override is not None:
endpoint_url = _validate_endpoint(endpoint_override)
else:
if wiiu_endpoint:
endpoint_url = _nus_endpoint[1]
else:
endpoint_url = _nus_endpoint[0]
ticket_url = endpoint_url + title_id + "/cetk"
# Make the request.
try:
ticket_request = requests.get(url=ticket_url, headers={'User-Agent': 'wii libnup/1.0'}, stream=True)
except requests.exceptions.ConnectionError:
if endpoint_override:
raise ValueError("A connection could not be made to the NUS endpoint. Please make sure that your endpoint "
"override is valid.")
else:
raise Exception("A connection could not be made to the NUS endpoint. The NUS may be unavailable.")
if ticket_request.status_code != 200:
raise ValueError("The requested Title ID does not exist, or refers to a non-free title. Tickets can only"
" be downloaded for titles that are free on the NUS.")
# Save the raw cetk file.
cetk = ticket_request.content
# Use a Ticket object to load only the Ticket data from cetk and return it.
ticket_temp = Ticket()
ticket_temp.load(cetk)
ticket = ticket_temp.dump()
return ticket
def download_cert_chain(wiiu_endpoint: bool = False, endpoint_override: str = None) -> bytes:
"""
Downloads the signing certificate chain used by all WADs. This uses System Menu 4.3U as the source.
Parameters
----------
wiiu_endpoint : bool, option
Whether the Wii U endpoint for the NUS should be used or not. This increases download speeds. Defaults to False.
endpoint_override: str, optional
A custom endpoint URL to use instead of the standard Wii or Wii U endpoints. Defaults to no override, and if
set entirely overrides the "wiiu_endpoint" parameter.
Returns
-------
bytes
The cert file.
"""
# Download the TMD and cetk for System Menu 4.3U (v513).
if endpoint_override is not None:
endpoint_url = _validate_endpoint(endpoint_override)
else:
if wiiu_endpoint:
endpoint_url = _nus_endpoint[1]
else:
endpoint_url = _nus_endpoint[0]
tmd_url = endpoint_url + "0000000100000002/tmd.513"
cetk_url = endpoint_url + "0000000100000002/cetk"
try:
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
except requests.exceptions.ConnectionError:
if endpoint_override:
raise ValueError("A connection could not be made to the NUS endpoint. Please make sure that your endpoint "
"override is valid.")
else:
raise Exception("A connection could not be made to the NUS endpoint. The NUS may be unavailable.")
# Assemble the certificate chain.
cert_chain = b''
# Certificate Authority data.
cert_chain += cetk[0x2A4 + 768:]
# Certificate Policy (TMD certificate) data.
cert_chain += tmd[0x328:0x328 + 768]
# XS (Ticket certificate) data.
cert_chain += cetk[0x2A4:0x2A4 + 768]
# Since the cert chain is always the same, check the hash to make sure nothing went wildly wrong.
# This is currently disabled because of the possibility that one may be downloading non-retail certs (gasp!).
#if hashlib.sha1(cert_chain).hexdigest() != "ace0f15d2a851c383fe4657afc3840d6ffe30ad0":
# raise Exception("An unknown error has occurred downloading and creating the certificate.")
return cert_chain
def download_content(title_id: str, content_id: int, wiiu_endpoint: bool = False,
endpoint_override: str = None) -> bytes:
"""
Downloads a specified content for the title specified in the object.
Parameters
----------
title_id : str
The Title ID of the title to download content from.
content_id : int
The Content ID of the content you wish to download.
wiiu_endpoint : bool, option
Whether the Wii U endpoint for the NUS should be used or not. This increases download speeds. Defaults to False.
endpoint_override: str, optional
A custom endpoint URL to use instead of the standard Wii or Wii U endpoints. Defaults to no override, and if
set entirely overrides the "wiiu_endpoint" parameter.
Returns
-------
bytes
The downloaded content.
"""
# Build the download URL. The structure is download/<TID>/<Content ID>.
content_id_hex = hex(content_id)[2:]
if len(content_id_hex) < 2:
content_id_hex = "0" + content_id_hex
if endpoint_override is not None:
endpoint_url = _validate_endpoint(endpoint_override)
else:
if wiiu_endpoint:
endpoint_url = _nus_endpoint[1]
else:
endpoint_url = _nus_endpoint[0]
content_url = endpoint_url + title_id + "/000000" + content_id_hex
# Make the request.
try:
content_request = requests.get(url=content_url, headers={'User-Agent': 'wii libnup/1.0'}, stream=True)
except requests.exceptions.ConnectionError:
if endpoint_override:
raise ValueError("A connection could not be made to the NUS endpoint. Please make sure that your endpoint "
"override is valid.")
else:
raise Exception("A connection could not be made to the NUS endpoint. The NUS may be unavailable.")
if content_request.status_code != 200:
raise ValueError("The requested Title ID does not exist, or an invalid Content ID is present in the"
" content records provided.\n Failed while downloading Content ID: 000000" +
content_id_hex)
content_data = content_request.content
return content_data
def download_contents(title_id: str, tmd: TMD, wiiu_endpoint: bool = False,
endpoint_override: str = None) -> List[bytes]:
"""
Downloads all the contents for the title specified in the object. This requires a TMD to already be available
so that the content records can be accessed.
Parameters
----------
title_id : str
The Title ID of the title to download content from.
tmd : TMD
The TMD that matches the title that the contents being downloaded are from.
wiiu_endpoint : bool, option
Whether the Wii U endpoint for the NUS should be used or not. This increases download speeds. Defaults to False.
endpoint_override: str, optional
A custom endpoint URL to use instead of the standard Wii or Wii U endpoints. Defaults to no override, and if
set entirely overrides the "wiiu_endpoint" parameter.
Returns
-------
List[bytes]
A list of all the downloaded contents.
"""
# Retrieve the content records from the TMD.
content_records = tmd.content_records
# Create a list of Content IDs to download.
content_ids = []
for content_record in content_records:
content_ids.append(content_record.content_id)
# Iterate over that list and download each content in it, then add it to the array of contents.
content_list = []
for content_id in content_ids:
# Call self.download_content() for each Content ID.
content = download_content(title_id, content_id, wiiu_endpoint, endpoint_override)
content_list.append(content)
return content_list
def _validate_endpoint(endpoint: str) -> str:
"""
Validate the provided NUS endpoint URL and append the required path if necessary.
Parameters
----------
endpoint: str
The NUS endpoint URL to validate.
Returns
-------
str
The validated NUS endpoint with the proper path.
"""
# Find the root of the URL and then assemble the correct URL based on that.
# TODO: Rewrite in a way that makes more sense and un-stub
#new_url = _urlparse(endpoint)
#if new_url.netloc == "":
# endpoint_url = "http://" + new_url.path + "/ccs/download/"
#else:
# endpoint_url = "http://" + new_url.netloc + "/ccs/download/"
return endpoint

View File

@ -0,0 +1,378 @@
# "title/ticket.py" from libWiiPy by NinjaCheetah & Contributors
# https://github.com/NinjaCheetah/libWiiPy
#
# See https://wiibrew.org/wiki/Ticket for details about the ticket format
import io
import binascii
import hashlib
from dataclasses import dataclass as _dataclass
from .crypto import decrypt_title_key
from typing import List
from .util import title_ver_standard_to_dec
@_dataclass
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. 0 and 3 are none, 1 is a time limit, and 4 is a launch count limit.
maximum_usage : int
The maximum value for the type of play limit applied.
"""
# The type of play limit applied.
limit_type: int
# The maximum value of the limit applied.
maximum_usage: int
class Ticket:
"""
A Ticket object that allows for either loading and editing an existing Ticket or creating one manually if desired.
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
The version of the ticket.
title_key_enc : bytes
The Title Key contained in the ticket, in encrypted form.
ticket_id : bytes
The unique ID of this ticket, used for console-specific title installations.
console_id : int
The unique ID of the console this ticket was designed for, if this is a console-specific ticket.
title_version : int
The version of the title this ticket was designed for.
common_key_index : int
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
# v0 ticket data
self.signature_issuer: str = "" # Who issued the signature for the ticket
self.ecdh_data: bytes = b'' # Involved in created one-time keys for console-specific title installs.
self.ticket_version: int = 0 # The version of the current ticket file.
self.title_key_enc: bytes = b'' # The title key of the ticket's respective title, encrypted by a common key.
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.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
# "Permit mask. The current disc title is ANDed with the inverse of this mask to see if the result matches the
# Permitted Titles Mask." -WiiBrew
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.
# v1 ticket data
# TODO: Write in v1 ticket attributes here. This code can currently only handle v0 tickets, and will reject v1.
def load(self, ticket: bytes) -> None:
"""
Loads raw Ticket data and sets all attributes of the WAD object. This allows for manipulating an already
existing Ticket.
Parameters
----------
ticket : bytes
The data for the Ticket you wish to load.
"""
with io.BytesIO(ticket) as ticket_data:
# ====================================================================================
# Parses each of the keys contained in the Ticket.
# ====================================================================================
# Signature type.
ticket_data.seek(0x0)
self.signature_type = ticket_data.read(4)
# Signature data.
ticket_data.seek(0x04)
self.signature = ticket_data.read(256)
# Signature issuer.
ticket_data.seek(0x140)
self.signature_issuer = str(ticket_data.read(64).replace(b'\x00', b'').decode())
# ECDH data.
ticket_data.seek(0x180)
self.ecdh_data = ticket_data.read(60)
# Ticket version.
ticket_data.seek(0x1BC)
self.ticket_version = int.from_bytes(ticket_data.read(1))
if self.ticket_version == 1:
raise ValueError("This appears to be a v1 ticket, which is not currently supported by libWiiPy. This "
"feature is planned for a later release. Only v0 tickets are supported at this time.")
# Title Key (Encrypted by a common key).
ticket_data.seek(0x1BF)
self.title_key_enc = ticket_data.read(16)
# Ticket ID.
ticket_data.seek(0x1D0)
self.ticket_id = ticket_data.read(8)
# Console ID.
ticket_data.seek(0x1D8)
self.console_id = int.from_bytes(ticket_data.read(4))
# Title ID.
ticket_data.seek(0x1DC)
self.title_id = ticket_data.read(8)
# Unknown data 1.
ticket_data.seek(0x1E4)
self.unknown1 = ticket_data.read(2)
# Title version.
ticket_data.seek(0x1E6)
self.title_version = int.from_bytes(ticket_data.read(2))
# Permitted titles mask.
ticket_data.seek(0x1E8)
self.permitted_titles = ticket_data.read(4)
# Permit mask.
ticket_data.seek(0x1EC)
self.permit_mask = ticket_data.read(4)
# Whether title export with a PRNG key is allowed.
ticket_data.seek(0x1F0)
self.title_export_allowed = int.from_bytes(ticket_data.read(1))
# Common key index.
ticket_data.seek(0x1F1)
self.common_key_index = int.from_bytes(ticket_data.read(1))
# Unknown data 2.
ticket_data.seek(0x1F2)
self.unknown2 = ticket_data.read(48)
# Content access permissions.
ticket_data.seek(0x222)
self.content_access_permissions = ticket_data.read(64)
# Content limits.
ticket_data.seek(0x264)
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))
# 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 or
self.signature_issuer.find("Root-CA00000002-XS00000004") != -1):
self.is_dev = True
else:
self.is_dev = False
def dump(self) -> bytes:
"""
Dumps the Ticket object back into bytes.
Returns
-------
bytes
The full Ticket file as bytes.
"""
ticket_data = b''
# Signature type.
ticket_data += self.signature_type
# Signature data.
ticket_data += self.signature
# Padding to 64 bytes.
ticket_data += b'\x00' * 60
# Signature issuer.
signature_issuer = self.signature_issuer.encode()
while len(signature_issuer) < 0x40:
signature_issuer += b'\x00'
ticket_data += signature_issuer
# ECDH data.
ticket_data += self.ecdh_data
# Ticket version.
ticket_data += int.to_bytes(self.ticket_version, 1)
# Reserved (all \0x00).
ticket_data += b'\x00\x00'
# Title Key.
ticket_data += self.title_key_enc
# Unknown (write \0x00).
ticket_data += b'\x00'
# Ticket ID.
ticket_data += self.ticket_id
# Console ID.
ticket_data += int.to_bytes(self.console_id, 4)
# Title ID.
ticket_data += self.title_id
# Unknown data 1.
ticket_data += self.unknown1
# Title version.
ticket_data += int.to_bytes(self.title_version, 2)
# Permitted titles mask.
ticket_data += self.permitted_titles
# Permit mask.
ticket_data += self.permit_mask
# Title Export allowed.
ticket_data += int.to_bytes(self.title_export_allowed, 1)
# Common Key index.
ticket_data += int.to_bytes(self.common_key_index, 1)
# Unknown data 2.
ticket_data += self.unknown2
# Content access permissions.
ticket_data += self.content_access_permissions
# Padding (always \x00).
ticket_data += b'\x00\x00'
# Iterate over Title Limit objects, write them back into raw data, then add them to the Ticket.
for title_limit in range(len(self.title_limits_list)):
title_limit_data = b''
# Write all fields from the title limit entry.
title_limit_data += int.to_bytes(self.title_limits_list[title_limit].limit_type, 4)
title_limit_data += int.to_bytes(self.title_limits_list[title_limit].maximum_usage, 4)
# Write the entry to the 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.
Returns
-------
str
The Title ID of the title.
"""
title_id_str = str(self.title_id.decode())
return title_id_str
def get_common_key_type(self) -> str:
"""
Gets the name of the common key used to encrypt the Title Key contained in the ticket.
Returns
-------
str
The name of the common key required.
See Also
--------
libWiiPy.title.commonkeys.get_common_key
"""
match self.common_key_index:
case 0:
return "Common"
case 1:
return "Korean"
case 2:
return "vWii"
case _:
return "Unknown"
def get_title_key(self) -> bytes:
"""
Gets the decrypted title key contained in the ticket.
Returns
-------
bytes
The decrypted title key.
"""
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 property of the Ticket. Recommended over setting the property directly because of input
validation.
Parameters
----------
title_id : str
The new Title ID of the title.
"""
if len(title_id) != 16:
raise ValueError("Invalid Title ID! Title IDs must be 8 bytes long.")
self.title_id = binascii.unhexlify(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.")

457
src/libWiiPy/title/title.py Normal file
View File

@ -0,0 +1,457 @@
# "title/title.py" from libWiiPy by NinjaCheetah & Contributors
# https://github.com/NinjaCheetah/libWiiPy
#
# See https://wiibrew.org/wiki/Title for details about how titles are formatted
import math
from .cert import (CertificateChain as _CertificateChain,
verify_ca_cert as _verify_ca_cert, verify_cert_sig as _verify_cert_sig,
verify_tmd_sig as _verify_tmd_sig, verify_ticket_sig as _verify_ticket_sig)
from .content import ContentRegion as _ContentRegion
from .ticket import Ticket as _Ticket
from .tmd import TMD as _TMD
from .wad import WAD as _WAD
from .crypto import encrypt_title_key
class Title:
"""
A Title object that contains all components of a title, and allows altering them. Provides higher-level access
than manually creating WAD, TMD, Ticket, and ContentRegion objects and ensures that any data that needs to match
between files matches.
Attributes
----------
wad: WAD
A WAD object of a WAD containing the title's data.
cert_chain: CertificateChain
The chain of certificates used to verify the contents of a title.
tmd: TMD
A TMD object of the title's TMD.
ticket: Ticket
A Ticket object of the title's Ticket.
content: ContentRegion
A ContentRegion object containing the title's contents.
"""
def __init__(self):
self.wad: _WAD = _WAD()
self.cert_chain: _CertificateChain = _CertificateChain()
self.tmd: _TMD = _TMD()
self.ticket: _Ticket = _Ticket()
self.content: _ContentRegion = _ContentRegion()
def load_wad(self, wad: bytes) -> None:
"""
Load existing WAD data into the title and create WAD, TMD, Ticket, and ContentRegion objects based off of it
to allow you to modify that data. Note that this will overwrite any existing data for this title.
Parameters
----------
wad : bytes
The data for the WAD you wish to load.
"""
# Create a new WAD object based on the WAD data provided.
self.wad = _WAD()
self.wad.load(wad)
# Load the certificate chain.
self.cert_chain = _CertificateChain()
self.cert_chain.load(self.wad.get_cert_data())
# Load the TMD.
self.tmd = _TMD()
self.tmd.load(self.wad.get_tmd_data())
# Load the ticket.
self.ticket = _Ticket()
self.ticket.load(self.wad.get_ticket_data())
# Load the content.
self.content = _ContentRegion()
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 != 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.")
def dump_wad(self) -> bytes:
"""
Dumps all title components (TMD, Ticket, and contents) back into the WAD object, and then dumps the WAD back
into raw data and returns it.
Returns
-------
wad_data : bytes
The raw data of the WAD.
"""
# Set WAD type to ib if the title being packed is boot2.
if self.tmd.title_id == "0000000100000001":
self.wad.wad_type = "ib"
# Dump the certificate chain and set it in the WAD.
self.wad.set_cert_data(self.cert_chain.dump())
# 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.
content_data, content_size = self.content.dump()
self.wad.set_content_data(content_data, content_size)
return self.wad.dump()
def load_cert_chain(self, cert_chain: bytes) -> None:
"""
Load an existing certificate chain into the title. Note that this will overwrite any existing certificate chain
data for this title.
Parameters
----------
cert_chain: bytes
The data for the certificate chain to load.
"""
self.cert_chain.load(cert_chain)
def load_tmd(self, tmd: bytes) -> None:
"""
Load existing TMD data into the title. Note that this will overwrite any existing TMD data for this title.
Parameters
----------
tmd : bytes
The data for the TMD to load.
"""
self.tmd.load(tmd)
def load_ticket(self, ticket: bytes) -> None:
"""
Load existing Ticket data into the title. Note that this will overwrite any existing Ticket data for this
title.
Parameters
----------
ticket : bytes
The data for the Ticket to load.
"""
self.ticket.load(ticket)
def load_content_records(self) -> None:
"""
Load content records from the TMD into the ContentRegion to allow loading content files based on the records.
This requires that a TMD has already been loaded and will throw an exception if it isn't.
"""
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, 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. This also re-encrypts the Title Key as the Title Key
is used as the IV for decrypting it.
Parameters
----------
title_id : str
The new Title ID of the 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 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.
Parameters
----------
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.
"""
if self.ticket.title_id == "":
raise ValueError("A Ticket must be loaded to get decrypted content.")
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, 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.
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.
"""
if self.ticket.title_id == "":
raise ValueError("A Ticket must be loaded to get decrypted content.")
dec_content = self.content.get_content_by_cid(cid, self.ticket.get_title_key(), skip_hash)
return dec_content
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:
"""
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 add.
cid : int
The Content ID to assign the new content in the content record.
index : int
The index used when encrypting the new content.
content_type : int
The type of the new content.
content_size : int
The size of the new encrypted content when decrypted.
content_hash : bytes
The hash of the new encrypted content when decrypted.
"""
# 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 add_content(self, dec_content: bytes, cid: int, content_type: int) -> None:
"""
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.
index : int
The index to place the new content at.
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, 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 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 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
def get_is_signed(self) -> bool:
"""
Uses the certificate chain to verify whether the Title object contains a properly signed title or not. This
verifies both the TMD and Ticket, and if either one fails verification then the title is not considered valid.
This will validate the entire certificate chain. If any part of the chain doesn't match the other pieces, then
this method will raise an exception.
Returns
-------
bool
Whether the title is properly signed or not.
See Also
--------
libWiiPy.title.cert
"""
# The entire chain needs to be verified, so start with the CA cert and work our way down. If anything fails
# along the way, future steps don't matter so exit the descending if's and return False.
try:
if _verify_ca_cert(self.cert_chain.ca_cert) is True:
if _verify_cert_sig(self.cert_chain.ca_cert, self.cert_chain.tmd_cert) is True:
if _verify_tmd_sig(self.cert_chain.tmd_cert, self.tmd) is True:
if _verify_cert_sig(self.cert_chain.ca_cert, self.cert_chain.ticket_cert) is True:
if _verify_ticket_sig(self.cert_chain.ticket_cert, self.ticket) is True:
return True
except ValueError:
raise ValueError("This title's certificate chain is not valid, or does not match the signature type of "
"the TMD/Ticket.")
return False

525
src/libWiiPy/title/tmd.py Normal file
View File

@ -0,0 +1,525 @@
# "title/tmd.py" from libWiiPy by NinjaCheetah & Contributors
# https://github.com/NinjaCheetah/libWiiPy
#
# See https://wiibrew.org/wiki/Title_metadata for details about the TMD format
import io
import binascii
import hashlib
import math
import struct
from typing import List
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:
"""
A TMD object that allows for either loading and editing an existing TMD or creating one manually if desired.
Attributes
----------
title_id : str
The title ID of the title listed in the TMD.
title_version : int
The version of the title listed in the TMD.
tmd_version : int
The version of the TMD.
ios_tid : str
The title ID of the IOS the title runs on.
ios_version : int
The IOS version the title runs on.
num_contents : int
The number of contents listed in the TMD.
"""
def __init__(self):
self.blob_header: bytes = b''
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
self.vwii: int = 0 # Whether the title is for the vWii. 0 = No, 1 = Yes
self.ios_tid: str = "" # The Title ID of the IOS version the associated title runs on.
self.ios_version: int = 0 # The IOS version the associated title runs on.
self.title_id: str = "" # The Title ID of 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: int = 0
self.title_version: int = 0 # The version of the associated title.
self.title_version_converted: int = 0 # The title version in vX.X format.
self.num_contents: int = 0 # The number of contents contained in the associated title.
self.boot_index: int = 0 # The content index that contains the bootable executable.
self.minor_version: int = 0 # Minor version (unused typically).
self.content_records: List[_ContentRecord] = []
def load(self, tmd: bytes) -> None:
"""
Loads raw TMD data and sets all attributes of the TMD object. This allows for manipulating an already
existing TMD.
Parameters
----------
tmd : bytes
The data for the TMD you wish to load.
"""
with io.BytesIO(tmd) as tmd_data:
# ====================================================================================
# Parses each of the keys contained in the TMD.
# ====================================================================================
# Signature type.
tmd_data.seek(0x0)
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.signature_issuer = str(tmd_data.read(64).replace(b'\x00', b'').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))
# Certificate Authority CRL version.
tmd_data.seek(0x181)
self.ca_crl_version = int.from_bytes(tmd_data.read(1))
# Certificate Policy CRL version.
tmd_data.seek(0x182)
self.signer_crl_version = int.from_bytes(tmd_data.read(1))
# If this is a vWii title or not.
tmd_data.seek(0x183)
self.vwii = int.from_bytes(tmd_data.read(1))
# TID of the IOS to use for the title, set to 0 if this title is the IOS, set to boot2 version if boot2.
tmd_data.seek(0x184)
ios_version_bin = tmd_data.read(8)
ios_version_hex = binascii.hexlify(ios_version_bin)
self.ios_tid = str(ios_version_hex.decode())
# Get IOS version based on TID.
self.ios_version = int(self.ios_tid[-2:], 16)
# Title ID of the title.
tmd_data.seek(0x18C)
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 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)
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))
# Region of the title, 0 = JAP, 1 = USA, 2 = EUR, 3 = WORLD, 4 = KOR.
tmd_data.seek(0x19C)
region_hex = tmd_data.read(2)
self.region = int.from_bytes(region_hex)
# Content rating of the title for parental controls. Likely based on ESRB, CERO, PEGI, etc. rating.
tmd_data.seek(0x19E)
self.ratings = tmd_data.read(16)
# "Reserved" data 1.
tmd_data.seek(0x1AE)
self.reserved1 = tmd_data.read(12)
# IPC mask.
tmd_data.seek(0x1BA)
self.ipc_mask = tmd_data.read(12)
# "Reserved" data 2.
tmd_data.seek(0x1C6)
self.reserved2 = tmd_data.read(18)
# Access rights of the title; DVD-video and AHB access.
tmd_data.seek(0x1D8)
self.access_rights = int.from_bytes(tmd_data.read(4))
# Version number straight from the TMD.
tmd_data.seek(0x1DC)
self.title_version = int.from_bytes(tmd_data.read(2))
# Calculate the converted version number 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])))
def dump(self) -> bytes:
"""
Dumps the TMD object back into bytes.
Returns
-------
bytes
The full TMD file as bytes.
"""
tmd_data = b''
# 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.
signature_issuer = self.signature_issuer.encode()
while len(signature_issuer) < 0x40:
signature_issuer += b'\x00'
tmd_data += signature_issuer
# TMD version.
tmd_data += int.to_bytes(self.tmd_version, 1)
# Certificate Authority CRL version.
tmd_data += int.to_bytes(self.ca_crl_version, 1)
# Certificate Policy CRL version.
tmd_data += int.to_bytes(self.signer_crl_version, 1)
# If this is a vWii title or not.
tmd_data += int.to_bytes(self.vwii, 1)
# IOS Title ID.
tmd_data += binascii.unhexlify(self.ios_tid)
# Title's Title ID.
tmd_data += binascii.unhexlify(self.title_id)
# Title type.
tmd_data += self.title_type
# Group ID.
tmd_data += int.to_bytes(self.group_id, 2)
# 2 bytes of zero for reasons.
tmd_data += b'\x00\x00'
# Region.
tmd_data += int.to_bytes(self.region, 2)
# Parental Controls Ratings.
tmd_data += self.ratings
# "Reserved" 1.
tmd_data += self.reserved1
# IPC mask.
tmd_data += self.ipc_mask
# "Reserved" 2.
tmd_data += self.reserved2
# Access rights.
tmd_data += int.to_bytes(self.access_rights, 4)
# Title version.
tmd_data += int.to_bytes(self.title_version, 2)
# Number of contents.
tmd_data += int.to_bytes(self.num_contents, 2)
# Boot index.
tmd_data += int.to_bytes(self.boot_index, 2)
# 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''
# Write all fields from the content record.
content_data += int.to_bytes(self.content_records[content_record].content_id, 4)
content_data += int.to_bytes(self.content_records[content_record].index, 2)
content_data += int.to_bytes(self.content_records[content_record].content_type, 2)
content_data += int.to_bytes(self.content_records[content_record].content_size, 8)
content_data += binascii.unhexlify(self.content_records[content_record].content_hash)
# Write the record to the 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 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:
'JPN', 'USA', 'EUR', 'None', or 'KOR'.
Returns
-------
str
The region of the title.
"""
match self.region:
case 0:
return "JPN"
case 1:
return "USA"
case 2:
return "EUR"
case 3:
return "None"
case 4:
return "KOR"
def get_title_type(self) -> str:
"""
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'
Returns
-------
str
The type of the title.
"""
match self.title_id[:8]:
case '00000001':
return "System"
case '00010000':
return "Game"
case '00010001':
return "Channel"
case '00010002':
return "SystemChannel"
case '00010004':
return "GameChannel"
case '00010005':
return "DLC"
case '00010008':
return "HiddenChannel"
case _:
return "Unknown"
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.
"""
# 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 2:
return "Development/Unknown"
case 3:
return "Hash Tree"
case 16385:
return "DLC"
case 32769:
return "Shared"
case _:
return "Unknown"
def get_content_record(self, record) -> _ContentRecord:
"""
Gets the content record at the specified index.
Parameters
----------
record : int
The content record to be retrieved.
Returns
-------
_ContentRecord
A ContentRecord object containing the data in the content record.
"""
if record < self.num_contents:
return self.content_records[record]
else:
raise IndexError("Invalid content record! TMD lists '" + str(self.num_contents - 1) +
"' contents but index was '" + str(record) + "'!")
def get_content_size(self, absolute=False, dlc=False) -> int:
"""
Gets the installed size of the content listed in the TMD, in bytes. This does not include the size of hash tree
content, so the size of disc titles will not be calculated. The "absolute" option determines whether shared
content sizes should be included in the total size or not. This option defaults to False. The "dlc" option
determines whether DLC content sizes should be included in the total size or not. This option also defaults to
False.
Parameters
----------
absolute: bool, optional
Whether shared contents should be included in the total size or not. Defaults to False.
dlc: bool, optional
Whether DLC contents should be included in the total size or not. Defaults to False.
Returns
-------
int
The installed size of the content, in bytes.
"""
title_size = 0
for record in self.content_records:
if record.content_type == 0x8001:
if absolute:
title_size += record.content_size
elif record.content_type == 0x4001:
if dlc:
title_size += record.content_size
elif record.content_type != 3:
title_size += record.content_size
return title_size
def get_content_size_blocks(self, absolute=False, dlc=False) -> int:
"""
Gets the installed size of the content listed in the TMD, 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. The "dlc" option determines whether DLC content sizes should be included in the total
size or not. This option also 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.
dlc: bool, optional
Whether DLC contents should be included in the total size or not. Defaults to False.
Returns
-------
int
The installed size of the content, in blocks.
"""
title_size_bytes = self.get_content_size(absolute, dlc)
blocks = math.ceil(title_size_bytes / 131072)
return blocks
class AccessFlags(_IntEnum):
AHB = 0
DVD_VIDEO = 1
def get_access_right(self, flag: int) -> bool:
"""
Gets whether the specified access rights flag is enabled or not. This is done by checking the specified bit.
Possible flags and their corresponding bits are defined in AccessFlags.
Parameters
----------
flag : int
The flag to check.
Returns
-------
bool
True if the flag is enabled, False otherwise.
See Also
--------
libWiiPy.title.tmd.TMD.AccessFlags
"""
return bool(self.access_rights & _bitmask(flag))
def set_title_id(self, title_id) -> None:
"""
Sets the Title ID property of the TMD. Recommended over setting the property directly because of input
validation.
Parameters
----------
title_id : str
The new Title ID of the title.
"""
if len(title_id) != 16:
raise ValueError("Invalid Title ID! Title IDs must be 8 bytes long.")
self.title_id = title_id
def set_title_version(self, new_version: str | int) -> None:
"""
Sets the version of the title in the TMD. Recommended over setting the data directly because of input
validation.
Accepts either standard form (vX.X) as a string or decimal form (vXXX) as an integer.
Parameters
----------
new_version : str, int
The new version of the title. See description for valid formats.
"""
if type(new_version) is str:
# Validate string input is in the correct format, then validate that the version isn't higher than v255.0.
# If checks pass, set that as the converted version, then convert to decimal form and set that as well.
version_str_split = new_version.split(".")
if len(version_str_split) != 2:
raise ValueError("Title version is not valid! String version must be entered in format \"X.X\".")
if int(version_str_split[0]) > 255 or int(version_str_split[1]) > 255:
raise ValueError("Title version is not valid! String version number cannot exceed v255.255.")
self.title_version_converted = new_version
version_converted = title_ver_standard_to_dec(new_version, self.title_id)
self.title_version = version_converted
elif type(new_version) is int:
# Validate that the version isn't higher than v65280. If the check passes, set that as the title version,
# then convert to standard form and set that as well.
if new_version > 65535:
raise ValueError("Title version is not valid! Integer version number cannot exceed v65535.")
self.title_version = new_version
version_converted = title_ver_dec_to_standard(new_version, self.title_id, bool(self.vwii))
self.title_version_converted = version_converted
else:
raise TypeError("Title version type is not valid! Type must be either integer or string.")

View File

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

View File

@ -1,11 +1,11 @@
# "wad.py" from libWiiPy by NinjaCheetah & Contributors
# "title/wad.py" from libWiiPy by NinjaCheetah & Contributors
# https://github.com/NinjaCheetah/libWiiPy
#
# See https://wiibrew.org/wiki/WAD_files for details about the WAD format
import io
import binascii
from .shared import align_value, pad_bytes_stream
from ..shared import _align_value, _pad_bytes
class WAD:
@ -15,7 +15,7 @@ class WAD:
Attributes
----------
wad_type : str
The type of WAD, either ib for boot2 or Is for normal installable WADs. libWiiPy only supports Is currently.
The type of WAD, either ib for boot2 or Is for normal installable WADs.
wad_cert_size : int
The size of the WAD's certificate.
wad_crl_size : int
@ -49,7 +49,7 @@ class WAD:
self.wad_content_data: bytes = b''
self.wad_meta_data: bytes = b''
def load(self, wad_data) -> None:
def load(self, wad_data: bytes) -> None:
"""
Loads raw WAD data and sets all attributes of the WAD object. This allows for manipulating an already
existing WAD file.
@ -57,17 +57,17 @@ class WAD:
Parameters
----------
wad_data : bytes
The data for the WAD you wish to load.
The data for the WAD file to load.
"""
with io.BytesIO(wad_data) as wad_data:
# Read the first 8 bytes of the file to ensure that it's a WAD. This will currently reject boot2 WADs, but
# this tool cannot handle them correctly right now anyway.
# Read the first 8 bytes of the file to ensure that it's a WAD. Has two possible valid values for the two
# different types of WADs that might be encountered.
wad_data.seek(0x0)
wad_magic_bin = wad_data.read(8)
wad_magic_hex = binascii.hexlify(wad_magic_bin)
wad_magic = str(wad_magic_hex.decode())
if wad_magic != "0000002049730000" and wad_magic != "0000002069620000":
raise TypeError("This does not appear to be a valid WAD file.")
raise TypeError("This is not a valid WAD file!")
# ====================================================================================
# Get the sizes of each data region contained within the WAD.
# ====================================================================================
@ -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.
# ====================================================================================
@ -140,50 +142,45 @@ class WAD:
bytes
The full WAD file as bytes.
"""
# Open the stream and begin writing data to it.
with io.BytesIO() as wad_data:
# Lead-in data.
wad_data.write(b'\x00\x00\x00\x20')
# WAD type.
wad_data.write(str.encode(self.wad_type))
# WAD version.
wad_data.write(self.wad_version)
# WAD cert size.
wad_data.write(int.to_bytes(self.wad_cert_size, 4))
# WAD crl size.
wad_data.write(int.to_bytes(self.wad_crl_size, 4))
# WAD ticket size.
wad_data.write(int.to_bytes(self.wad_tik_size, 4))
# WAD TMD size.
wad_data.write(int.to_bytes(self.wad_tmd_size, 4))
# WAD content size.
wad_data.write(int.to_bytes(self.wad_content_size, 4))
# WAD meta size.
wad_data.write(int.to_bytes(self.wad_meta_size, 4))
wad_data = pad_bytes_stream(wad_data)
# Retrieve the cert data and write it out.
wad_data.write(self.get_cert_data())
wad_data = pad_bytes_stream(wad_data)
# Retrieve the crl data and write it out.
wad_data.write(self.get_crl_data())
wad_data = pad_bytes_stream(wad_data)
# Retrieve the ticket data and write it out.
wad_data.write(self.get_ticket_data())
wad_data = pad_bytes_stream(wad_data)
# Retrieve the TMD data and write it out.
wad_data.write(self.get_tmd_data())
wad_data = pad_bytes_stream(wad_data)
# Retrieve the meta/footer data and write it out.
wad_data.write(self.get_meta_data())
wad_data = pad_bytes_stream(wad_data)
# Retrieve the content data and write it out.
wad_data.write(self.get_content_data())
wad_data = pad_bytes_stream(wad_data)
# Seek to the beginning and save this as the WAD data for the object.
wad_data.seek(0x0)
wad_data_raw = wad_data.read()
# Return the raw WAD file for the data contained in the object.
return wad_data_raw
wad_data = b''
# Lead-in data.
wad_data += b'\x00\x00\x00\x20'
# WAD type.
wad_data += str.encode(self.wad_type)
# WAD version.
wad_data += self.wad_version
# WAD cert size.
wad_data += int.to_bytes(self.wad_cert_size, 4)
# WAD crl size.
wad_data += int.to_bytes(self.wad_crl_size, 4)
# WAD ticket size.
wad_data += int.to_bytes(self.wad_tik_size, 4)
# WAD TMD size.
wad_data += int.to_bytes(self.wad_tmd_size, 4)
# WAD content size.
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)
# Retrieve the cert data and write it out.
wad_data += self.get_cert_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)
# Retrieve the ticket data and write it out.
wad_data += self.get_ticket_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 content data and write it out.
wad_data += self.get_content_data()
wad_data = _pad_bytes(wad_data)
# Retrieve the meta/footer data and write it out.
wad_data += self.get_meta_data()
wad_data = _pad_bytes(wad_data)
return wad_data
def get_wad_type(self) -> str:
"""
@ -314,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.
@ -322,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:
"""

View File

@ -1,343 +0,0 @@
# "tmd.py" from libWiiPy by NinjaCheetah & Contributors
# https://github.com/NinjaCheetah/libWiiPy
#
# See https://wiibrew.org/wiki/Title_metadata for details about the TMD format
import io
import binascii
import struct
from typing import List
from .types import ContentRecord
class TMD:
"""
A TMD object that allows for either loading and editing an existing TMD or creating one manually if desired.
Attributes
----------
title_id : str
The title ID of the title listed in the TMD.
title_version : int
The version of the title listed in the TMD.
tmd_version : int
The version of the TMD.
ios_tid : str
The title ID of the IOS the title runs on.
ios_version : int
The IOS version the title runs on.
num_contents : int
The number of contents listed in the 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.tmd_version: int = 0 # This seems to always be 0 no matter what?
self.ca_crl_version: int = 0
self.signer_crl_version: int = 0
self.vwii: int = 0 # Whether the title is for the vWii. 0 = No, 1 = Yes
self.ios_tid: str = "" # The Title ID of the IOS version the associated title runs on.
self.ios_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.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''
self.ipc_mask: bytes = b''
self.access_rights: bytes = b''
self.title_version: int = 0 # The version of the associated title.
self.num_contents: int = 0 # The number of contents contained in the associated title.
self.boot_index: int = 0
self.content_records: List[ContentRecord] = []
def load(self, tmd: bytes) -> None:
"""
Loads raw TMD data and sets all attributes of the WAD object. This allows for manipulating an already
existing TMD.
Parameters
----------
tmd : bytes
The data for the TMD you wish to load.
"""
with io.BytesIO(tmd) as tmd_data:
# ====================================================================================
# Parses each of the keys contained in the TMD.
# ====================================================================================
tmd_data.seek(0x0)
self.blob_header = tmd_data.read(320)
# Signing certificate issuer.
tmd_data.seek(0x140)
self.issuer = tmd_data.read(64)
# 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))
# Root certificate crl version.
tmd_data.seek(0x181)
self.ca_crl_version = int.from_bytes(tmd_data.read(1))
# Signer crl version.
tmd_data.seek(0x182)
self.signer_crl_version = int.from_bytes(tmd_data.read(1))
# If this is a vWii title or not.
tmd_data.seek(0x183)
self.vwii = int.from_bytes(tmd_data.read(1))
# TID of the IOS to use for the title, set to 0 if this title is the IOS, set to boot2 version if boot2.
tmd_data.seek(0x184)
ios_version_bin = tmd_data.read(8)
ios_version_hex = binascii.hexlify(ios_version_bin)
self.ios_tid = str(ios_version_hex.decode())
# Get IOS version based on TID.
self.ios_version = int(self.ios_tid[-2:], 16)
# Title ID of the title.
tmd_data.seek(0x18C)
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.
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())
# Publisher of the title.
tmd_data.seek(0x198)
self.group_id = int.from_bytes(tmd_data.read(2))
# Region of the title, 0 = JAP, 1 = USA, 2 = EUR, 3 = NONE, 4 = KOR.
tmd_data.seek(0x19C)
region_hex = tmd_data.read(2)
self.region = int.from_bytes(region_hex)
# Likely the localized content rating for the title. (ESRB, CERO, PEGI, etc.)
tmd_data.seek(0x19E)
self.ratings = tmd_data.read(16)
# IPC mask.
tmd_data.seek(0x1BA)
self.ipc_mask = tmd_data.read(12)
# Access rights of the title; DVD-video access and AHBPROT.
tmd_data.seek(0x1D8)
self.access_rights = tmd_data.read(4)
# Calculate the version number by multiplying 0x1DC by 256 and adding 0x1DD.
tmd_data.seek(0x1DC)
title_version_high = int.from_bytes(tmd_data.read(1)) * 256
tmd_data.seek(0x1DD)
title_version_low = int.from_bytes(tmd_data.read(1))
self.title_version = title_version_high + title_version_low
# The number of contents listed in the TMD.
tmd_data.seek(0x1DE)
self.num_contents = int.from_bytes(tmd_data.read(2))
# Content index in content list that contains the boot file.
tmd_data.seek(0x1E0)
self.boot_index = 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])))
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.
Returns
-------
bytes
The full TMD file as bytes.
"""
# Open the stream and begin writing to it.
with io.BytesIO() as tmd_data:
# Signed blob header.
tmd_data.write(self.blob_header)
# Signing certificate issuer.
tmd_data.write(self.issuer)
# TMD version.
tmd_data.write(int.to_bytes(self.tmd_version, 1))
# Root certificate crl version.
tmd_data.write(int.to_bytes(self.ca_crl_version, 1))
# Signer crl version.
tmd_data.write(int.to_bytes(self.signer_crl_version, 1))
# If this is a vWii title or not.
tmd_data.write(int.to_bytes(self.vwii, 1))
# IOS Title ID.
tmd_data.write(binascii.unhexlify(self.ios_tid))
# Title's Title ID.
tmd_data.write(binascii.unhexlify(self.title_id))
# Content type.
tmd_data.write(binascii.unhexlify(self.content_type))
# Group ID.
tmd_data.write(int.to_bytes(self.group_id, 2))
# 2 bytes of zero for reasons.
tmd_data.write(b'\x00\x00')
# Region.
tmd_data.write(int.to_bytes(self.region, 2))
# Ratings.
tmd_data.write(self.ratings)
# Reserved (all \x00).
tmd_data.write(b'\x00' * 12)
# IPC mask.
tmd_data.write(self.ipc_mask)
# Reserved (all \x00).
tmd_data.write(b'\x00' * 18)
# Access rights.
tmd_data.write(self.access_rights)
# Title version.
title_version_high = round(self.title_version / 256)
tmd_data.write(int.to_bytes(title_version_high, 1))
title_version_low = self.title_version % 256
tmd_data.write(int.to_bytes(title_version_low, 1))
# Number of contents.
tmd_data.write(int.to_bytes(self.num_contents, 2))
# Boot index.
tmd_data.write(int.to_bytes(self.boot_index, 2))
# Minor version. Unused so write \x00.
tmd_data.write(b'\x00\x00')
# 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 = io.BytesIO()
# Write all fields from the content record.
content_data.write(int.to_bytes(self.content_records[content_record].content_id, 4))
content_data.write(int.to_bytes(self.content_records[content_record].index, 2))
content_data.write(int.to_bytes(self.content_records[content_record].content_type, 2))
content_data.write(int.to_bytes(self.content_records[content_record].content_size, 8))
content_data.write(binascii.unhexlify(self.content_records[content_record].content_hash))
# Seek to the start and write the record to the TMD.
content_data.seek(0x0)
tmd_data.write(content_data.read())
content_data.close()
# Set the TMD attribute of the object to the new raw TMD.
tmd_data.seek(0x0)
tmd_data_raw = tmd_data.read()
# Return the raw TMD for the data contained in the object.
return tmd_data_raw
def get_title_region(self) -> str:
"""
Gets the region of the TMD's associated title.
Can be one of several possible values:
'JAP', 'USA', 'EUR', 'NONE', or 'KOR'.
Returns
-------
str
The region of the title.
"""
match self.region:
case 0:
return "JAP"
case 1:
return "USA"
case 2:
return "EUR"
case 3:
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.
Can be one of several possible values:
'System', 'Game', 'Channel', 'SystemChannel', 'GameChannel', or 'HiddenChannel'
Returns
-------
str
The type of the title.
"""
title_id_high = self.title_id[:8]
match title_id_high:
case '00000001':
return "System"
case '00010000':
return "Game"
case '00010001':
return "Channel"
case '00010002':
return "SystemChannel"
case '00010004':
return "GameChannel"
case '00010005':
return "DLC"
case '00010008':
return "HiddenChannel"
case _:
return "Unknown"
def get_content_type(self):
"""
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'
Returns
-------
str
The type of content.
"""
match self.content_type:
case '00000001':
return "Normal"
case '00000002':
return "Development/Unknown"
case '00000003':
return "Hash Tree"
case '00004001':
return "DLC"
case '00008001':
return "Shared"
case _:
return "Unknown"
def get_content_record(self, record) -> ContentRecord:
"""
Gets the content record at the specified index.
Parameters
----------
record : int
The content record to be retrieved.
Returns
-------
ContentRecord
A ContentRecord object containing the data in the content record.
"""
if record < self.num_contents:
return self.content_records[record]
else:
raise IndexError("Invalid content record! TMD lists '" + str(self.num_contents - 1) +
"' contents but index was '" + str(record) + "'!")
def set_title_id(self, title_id) -> None:
"""
Sets the Title ID of the title in the ticket.
Parameters
----------
title_id : str
The new Title ID of the title.
"""
if len(title_id) != 16:
raise ValueError("Invalid Title ID! Title IDs must be 8 bytes long.")
self.title_id = title_id

View File

@ -5,7 +5,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
@ -14,39 +14,18 @@ class ContentRecord:
Attributes
----------
content_id : int
ID of the content.
The unique ID of the content.
index : int
Index of the content in the list of contents.
The index of this content in the content records.
content_type : int
The type of the content.
content_size : int
The size of the content.
The size of the content when decrypted.
content_hash
The SHA-1 hash of the decrypted content.
"""
content_id: int # The unique ID of the content.
index: int # The index of this content in the content record.
content_id: int
index: int
content_type: int # Type of content, possible values of: 0x0001: Normal, 0x4001: DLC, 0x8001: Shared.
content_size: int # Size of the content when decrypted.
content_hash: bytes # SHA-1 hash of the content when decrypted.
@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
content_size: int
content_hash: bytes

12
test/__init__.py Normal file
View File

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

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

View File

@ -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
View File

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