mirror of
https://github.com/NinjaCheetah/libWiiPy.git
synced 2025-04-26 13:21:01 -04:00
Compare commits
42 Commits
Author | SHA1 | Date | |
---|---|---|---|
e06bb39f4c | |||
8269a0db98 | |||
8adbef26b1 | |||
5dde9f7835 | |||
93abad1f31 | |||
9eabf2caee | |||
5ae867197b | |||
6552dc5fa8 | |||
93790d6f58 | |||
f0b79e1f39 | |||
06b36290ed | |||
47472e7b94 | |||
7c5af6ebe0 | |||
046645eb56 | |||
e45c7a3076 | |||
c2f6225500 | |||
04d17a58d2 | |||
aa9e8fb4ea | |||
8a15b1e82e | |||
ece19177c4 | |||
3a44eaf2cf | |||
2fdd808137 | |||
f98a3703a4 | |||
1e6952c2b2 | |||
944fb896b5 | |||
3d4d3dc99e | |||
62f99165c7 | |||
e227f4e2be | |||
da16259938 | |||
1cce0f14ee | |||
c86b44f35c | |||
1ff4ecdf68 | |||
302bd842d1 | |||
c5a007e1f5 | |||
e96f6d9f13 | |||
57b2ed63d4 | |||
855200bb98 | |||
cfd105ba81 | |||
ed7e928ad8 | |||
6b18254edc | |||
1b6e0db26d | |||
9ae059b797 |
15
README.md
15
README.md
@ -1,23 +1,27 @@
|
|||||||

|

|
||||||
# libWiiPy
|
# libWiiPy
|
||||||
libWiiPy is a modern Python 3 library for handling the various files and formats found on the Wii. It aims to be simple to use, well maintained, and offer as many features as reasonably possible in one library, so that a newly-written Python program could do 100% of its Wii-related work with just one library. It also aims to be fully cross-platform, so that any tools written with it can also be cross-platform.
|
libWiiPy is a modern Python 3 library for handling the various files and formats found on the Wii. It aims to be simple to use, well maintained, and offer as many features as reasonably possible in one library, so that a newly-written Python program could do 100% of its Wii-related work with just one library. It also aims to be fully cross-platform, so that any tools written with it can also be cross-platform.
|
||||||
|
|
||||||
libWiiPy is inspired by [libWiiSharp](https://github.com/TheShadowEevee/libWiiSharp), which was originally created by `Leathl` and is now maintained by [@TheShadowEevee](https://github.com/TheShadowEevee). If you're looking for a Wii library that isn't in Python, then go check it out!
|
libWiiPy is inspired by [libWiiSharp](https://github.com/TheShadowEevee/libWiiSharp), which was originally created by `Leathl` and is now maintained by [@TheShadowEevee](https://github.com/TheShadowEevee).
|
||||||
|
|
||||||
|
|
||||||
# Features
|
# Features
|
||||||
This list will expand as libWiiPy is developed, but these features are currently available:
|
This list will expand as libWiiPy is developed, but these features are currently available:
|
||||||
- TMD and Ticket parsing/editing (`.tmd`, `.tik`)
|
- TMD and Ticket parsing/editing (`.tmd`, `.tik`)
|
||||||
- Title parsing/editing, including content encryption/decryption
|
- Title parsing/editing, including content encryption/decryption (both retail and development)
|
||||||
- WAD file parsing/editing (`.wad`)
|
- WAD file parsing/editing (`.wad`)
|
||||||
- Downloading titles from the NUS
|
- Downloading titles and their components from the NUS
|
||||||
|
- Certificate, TMD, and Ticket signature verification
|
||||||
- Packing and unpacking U8 archives (`.app`, `.arc`)
|
- Packing and unpacking U8 archives (`.app`, `.arc`)
|
||||||
- Decompressing ASH files (`.ash`, both the standard variants and the variants found in My Pokémon Ranch)
|
- Decompressing ASH files (`.ash`, both the standard variants and the variants found in My Pokémon Ranch)
|
||||||
|
- Compressing/Decompressing LZ77-compressed files
|
||||||
- IOS patching
|
- IOS patching
|
||||||
- NAND-related functionality:
|
- NAND-related functionality:
|
||||||
- EmuNAND title management (currently requires an existing EmuNAND)
|
- EmuNAND title management (currently requires an existing EmuNAND)
|
||||||
- `content.map` parsing/editing
|
- `content.map` parsing/editing
|
||||||
|
- `setting.txt` parsing/editing
|
||||||
- `uid.sys` parsing/editing
|
- `uid.sys` parsing/editing
|
||||||
|
- Limited channel banner parsing/editing
|
||||||
- Assorted miscellaneous features used to make the other core features possible
|
- 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).
|
For a more detailed look at what's available in libWiiPy, check out our [API docs](https://ninjacheetah.github.io/libWiiPy).
|
||||||
@ -68,6 +72,3 @@ Thank you to all of the contributors to the documentation on the WiiBrew pages t
|
|||||||
|
|
||||||
### One additional special thanks to [@DamiDoop](https://github.com/DamiDoop)!
|
### One additional special thanks to [@DamiDoop](https://github.com/DamiDoop)!
|
||||||
She made the very cool banner you can see at the top of this README, and has also helped greatly with my sanity throughout debugging this library.
|
She made the very cool banner you can see at the top of this README, and has also helped greatly with my sanity throughout debugging this library.
|
||||||
|
|
||||||
**Note:** While libWiiPy is directly inspired by libWiiSharp and aims to have feature parity with it, no code from either libWiiSharp or Wii.py was used in the making of this library. All code is original and is written by [@NinjaCheetah](https://github.com/NinjaCheetah), [@rvtr](https://github.com/rvtr), and any other GitHub contributors.
|
|
||||||
|
|
||||||
|
23
docs/source/api.md
Normal file
23
docs/source/api.md
Normal 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
|
||||||
|
```
|
23
docs/source/archive/archive.md
Normal file
23
docs/source/archive/archive.md
Normal 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
|
||||||
|
```
|
16
docs/source/archive/ash.md
Normal file
16
docs/source/archive/ash.md
Normal 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:
|
||||||
|
```
|
14
docs/source/archive/lz77.md
Normal file
14
docs/source/archive/lz77.md
Normal 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
16
docs/source/archive/u8.md
Normal 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:
|
||||||
|
```
|
Binary file not shown.
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 82 KiB |
BIN
docs/source/banner_old.png
Normal file
BIN
docs/source/banner_old.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 15 KiB |
@ -11,6 +11,7 @@ from datetime import date
|
|||||||
project = 'libWiiPy'
|
project = 'libWiiPy'
|
||||||
copyright = f'{date.today().year}, NinjaCheetah & Contributors'
|
copyright = f'{date.today().year}, NinjaCheetah & Contributors'
|
||||||
author = 'NinjaCheetah & Contributors'
|
author = 'NinjaCheetah & Contributors'
|
||||||
|
version = 'main'
|
||||||
release = 'main'
|
release = 'main'
|
||||||
|
|
||||||
# -- General configuration ---------------------------------------------------
|
# -- General configuration ---------------------------------------------------
|
||||||
@ -30,7 +31,8 @@ html_logo = "banner.png"
|
|||||||
html_title = "libWiiPy API Docs"
|
html_title = "libWiiPy API Docs"
|
||||||
html_theme_options = {
|
html_theme_options = {
|
||||||
"repository_url": "https://github.com/NinjaCheetah/libWiiPy",
|
"repository_url": "https://github.com/NinjaCheetah/libWiiPy",
|
||||||
"use_repository_button": True
|
"use_repository_button": True,
|
||||||
|
"show_toc_level": 3
|
||||||
}
|
}
|
||||||
|
|
||||||
# MyST Configuration
|
# MyST Configuration
|
||||||
|
@ -4,9 +4,11 @@ sd_hide_title: true
|
|||||||
|
|
||||||
# Overview
|
# Overview
|
||||||
|
|
||||||
# libWiiPy API Docs
|
# libWiiPy Documentation
|
||||||
|
|
||||||
Welcome to the API documentation website for libWiiPy! libWiiPy is a modern Python 3 library for handling the various files and formats found on the Wii.
|
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}
|
```{toctree}
|
||||||
:hidden:
|
:hidden:
|
||||||
@ -34,13 +36,12 @@ titles/nus-downloading.md
|
|||||||
|
|
||||||
```{toctree}
|
```{toctree}
|
||||||
:hidden:
|
:hidden:
|
||||||
:caption: Other Useful Pages
|
:caption: More
|
||||||
|
|
||||||
modules.md
|
api.md
|
||||||
```
|
```
|
||||||
|
|
||||||
## Indices and tables
|
## Indices and tables
|
||||||
|
|
||||||
* [Full Index](<project:#genindex>)
|
* [Full Index](<project:#genindex>)
|
||||||
* [Module Index](<project:#modules>)
|
|
||||||
* <project:#search>
|
* <project:#search>
|
||||||
|
@ -1,28 +0,0 @@
|
|||||||
# libWiiPy.archive package
|
|
||||||
|
|
||||||
## Submodules
|
|
||||||
|
|
||||||
### libWiiPy.archive.ash module
|
|
||||||
|
|
||||||
```{eval-rst}
|
|
||||||
.. automodule:: libWiiPy.archive.ash
|
|
||||||
:members:
|
|
||||||
:undoc-members:
|
|
||||||
:show-inheritance:
|
|
||||||
```
|
|
||||||
### libWiiPy.archive.u8 module
|
|
||||||
|
|
||||||
```{eval-rst}
|
|
||||||
.. automodule:: libWiiPy.archive.u8
|
|
||||||
:members:
|
|
||||||
:undoc-members:
|
|
||||||
:show-inheritance:
|
|
||||||
```
|
|
||||||
## Module contents
|
|
||||||
|
|
||||||
```{eval-rst}
|
|
||||||
.. automodule:: libWiiPy.archive
|
|
||||||
:members:
|
|
||||||
:undoc-members:
|
|
||||||
:show-inheritance:
|
|
||||||
```
|
|
@ -1,35 +0,0 @@
|
|||||||
# libWiiPy package
|
|
||||||
|
|
||||||
## Subpackages
|
|
||||||
|
|
||||||
```{toctree}
|
|
||||||
:maxdepth: 4
|
|
||||||
|
|
||||||
libWiiPy.archive
|
|
||||||
libWiiPy.nand
|
|
||||||
libWiiPy.title
|
|
||||||
```
|
|
||||||
|
|
||||||
## Submodules
|
|
||||||
|
|
||||||
### libWiiPy.shared module
|
|
||||||
|
|
||||||
libWiiPy's ``shared`` module is private and contains only private functions used by other modules.
|
|
||||||
|
|
||||||
```{eval-rst}
|
|
||||||
.. automodule:: libWiiPy.shared
|
|
||||||
:members:
|
|
||||||
:undoc-members:
|
|
||||||
:show-inheritance:
|
|
||||||
```
|
|
||||||
|
|
||||||
### libWiiPy.types module
|
|
||||||
|
|
||||||
libWiiPy's ``types`` module is private and contains only private classes used by other modules.
|
|
||||||
|
|
||||||
```{eval-rst}
|
|
||||||
.. automodule:: libWiiPy.types
|
|
||||||
:members:
|
|
||||||
:undoc-members:
|
|
||||||
:show-inheritance:
|
|
||||||
```
|
|
@ -1,27 +0,0 @@
|
|||||||
# libWiiPy.nand package
|
|
||||||
|
|
||||||
## Submodules
|
|
||||||
|
|
||||||
### libWiiPy.nand.emunand module
|
|
||||||
```{eval-rst}
|
|
||||||
.. automodule:: libWiiPy.nand.emunand
|
|
||||||
:members:
|
|
||||||
:undoc-members:
|
|
||||||
:show-inheritance:
|
|
||||||
```
|
|
||||||
|
|
||||||
### libWiiPy.nand.setting module
|
|
||||||
```{eval-rst}
|
|
||||||
.. automodule:: libWiiPy.nand.setting
|
|
||||||
:members:
|
|
||||||
:undoc-members:
|
|
||||||
:show-inheritance:
|
|
||||||
```
|
|
||||||
|
|
||||||
### libWiiPy.nand.sys module
|
|
||||||
```{eval-rst}
|
|
||||||
.. automodule:: libWiiPy.nand.sys
|
|
||||||
:members:
|
|
||||||
:undoc-members:
|
|
||||||
:show-inheritance:
|
|
||||||
```
|
|
@ -1,91 +0,0 @@
|
|||||||
# libWiiPy.title package
|
|
||||||
|
|
||||||
## Submodules
|
|
||||||
|
|
||||||
### libWiiPy.title.commonkeys module
|
|
||||||
```{eval-rst}
|
|
||||||
.. automodule:: libWiiPy.title.commonkeys
|
|
||||||
:members:
|
|
||||||
:undoc-members:
|
|
||||||
:show-inheritance:
|
|
||||||
```
|
|
||||||
|
|
||||||
### libWiiPy.title.content module
|
|
||||||
```{eval-rst}
|
|
||||||
.. automodule:: libWiiPy.title.content
|
|
||||||
:members:
|
|
||||||
:undoc-members:
|
|
||||||
:show-inheritance:
|
|
||||||
```
|
|
||||||
|
|
||||||
### libWiiPy.title.crypto module
|
|
||||||
```{eval-rst}
|
|
||||||
.. automodule:: libWiiPy.title.crypto
|
|
||||||
:members:
|
|
||||||
:undoc-members:
|
|
||||||
:show-inheritance:
|
|
||||||
```
|
|
||||||
|
|
||||||
### libWiipy.title.iospatcher module
|
|
||||||
```{eval-rst}
|
|
||||||
.. automodule:: libWiiPy.title.iospatcher
|
|
||||||
:members:
|
|
||||||
:undoc-members:
|
|
||||||
:show-inheritance:
|
|
||||||
```
|
|
||||||
|
|
||||||
### libWiiPy.title.nus module
|
|
||||||
```{eval-rst}
|
|
||||||
.. automodule:: libWiiPy.title.nus
|
|
||||||
:members:
|
|
||||||
:undoc-members:
|
|
||||||
:show-inheritance:
|
|
||||||
```
|
|
||||||
|
|
||||||
### libWiiPy.title.ticket module
|
|
||||||
```{eval-rst}
|
|
||||||
.. automodule:: libWiiPy.title.ticket
|
|
||||||
:members:
|
|
||||||
:undoc-members:
|
|
||||||
:show-inheritance:
|
|
||||||
```
|
|
||||||
|
|
||||||
### libWiiPy.title.title module
|
|
||||||
```{eval-rst}
|
|
||||||
.. automodule:: libWiiPy.title.title
|
|
||||||
:members:
|
|
||||||
:undoc-members:
|
|
||||||
:show-inheritance:
|
|
||||||
```
|
|
||||||
|
|
||||||
### libWiiPy.title.tmd module
|
|
||||||
```{eval-rst}
|
|
||||||
.. automodule:: libWiiPy.title.tmd
|
|
||||||
:members:
|
|
||||||
:undoc-members:
|
|
||||||
:show-inheritance:
|
|
||||||
```
|
|
||||||
|
|
||||||
### libWiiPy.title.util module
|
|
||||||
```{eval-rst}
|
|
||||||
.. automodule:: libWiiPy.title.util
|
|
||||||
:members:
|
|
||||||
:undoc-members:
|
|
||||||
:show-inheritance:
|
|
||||||
```
|
|
||||||
|
|
||||||
### libWiiPy.title.wad module
|
|
||||||
```{eval-rst}
|
|
||||||
.. automodule:: libWiiPy.title.wad
|
|
||||||
:members:
|
|
||||||
:undoc-members:
|
|
||||||
:show-inheritance:
|
|
||||||
```
|
|
||||||
|
|
||||||
## Module contents
|
|
||||||
```{eval-rst}
|
|
||||||
.. automodule:: libWiiPy.title
|
|
||||||
:members:
|
|
||||||
:undoc-members:
|
|
||||||
:show-inheritance:
|
|
||||||
```
|
|
14
docs/source/media/banner.md
Normal file
14
docs/source/media/banner.md
Normal 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:
|
||||||
|
```
|
19
docs/source/media/media.md
Normal file
19
docs/source/media/media.md
Normal 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
|
||||||
|
```
|
@ -1,7 +0,0 @@
|
|||||||
# Modules Overview
|
|
||||||
|
|
||||||
```{toctree}
|
|
||||||
:maxdepth: 4
|
|
||||||
|
|
||||||
libWiiPy
|
|
||||||
```
|
|
14
docs/source/nand/emunand.md
Normal file
14
docs/source/nand/emunand.md
Normal 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
23
docs/source/nand/nand.md
Normal 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
|
||||||
|
```
|
16
docs/source/nand/setting.md
Normal file
16
docs/source/nand/setting.md
Normal 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
14
docs/source/nand/sys.md
Normal 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
16
docs/source/title/cert.md
Normal 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:
|
||||||
|
```
|
14
docs/source/title/commonkeys.md
Normal file
14
docs/source/title/commonkeys.md
Normal 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:
|
||||||
|
```
|
14
docs/source/title/content.md
Normal file
14
docs/source/title/content.md
Normal 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:
|
||||||
|
```
|
14
docs/source/title/crypto.md
Normal file
14
docs/source/title/crypto.md
Normal 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:
|
||||||
|
```
|
14
docs/source/title/iospatcher.md
Normal file
14
docs/source/title/iospatcher.md
Normal 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
14
docs/source/title/nus.md
Normal 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:
|
||||||
|
```
|
14
docs/source/title/ticket.md
Normal file
14
docs/source/title/ticket.md
Normal 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:
|
||||||
|
```
|
38
docs/source/title/title.md
Normal file
38
docs/source/title/title.md
Normal 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
|
||||||
|
```
|
17
docs/source/title/title.title.md
Normal file
17
docs/source/title/title.title.md
Normal 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
14
docs/source/title/tmd.md
Normal 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
14
docs/source/title/util.md
Normal 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
14
docs/source/title/wad.md
Normal 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:
|
||||||
|
```
|
@ -34,7 +34,7 @@ And viola! We have a WAD object that we can use to get each separate part of our
|
|||||||
|
|
||||||
## Picking the WAD Apart
|
## Picking the WAD Apart
|
||||||
|
|
||||||
Now that we have our WAD loaded, we need to separate it out into its components. On top of the parts we already established, a WAD also contains a certificate, checked by IOS during official title installations to ensure that a title was signed by Nintendo, and potentially two more areas called the footer and the CRL. Footers aren't a necessary part of a WAD, and when they do exist, they typically only contain the build timestamp and the machine it was built on. CRLs are even less common, and have never actually been found inside any WAD, but we know they exist because of things we've seen that Nintendo would really rather we hadn't. Because these three components don't have data we can edit, they're only ever represented as bytes, and do not have their own classes.
|
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
|
### The TMD
|
||||||
|
|
||||||
@ -110,11 +110,19 @@ Now that we know things are working, why don't we speed things up a little by us
|
|||||||
|
|
||||||
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.
|
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
|
### The Other Data
|
||||||
|
|
||||||
As mentioned earlier in this guide, WADs also contain up to three extra regions of data: the certificate, the footer, and the CRL. The procedure for extracting all of these is pretty simple, and follows the same formula as any other data in a WAD:
|
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
|
```pycon
|
||||||
>>> certificate = wad.get_cert_data()
|
|
||||||
>>> footer = wad.get_meta_data()
|
>>> footer = wad.get_meta_data()
|
||||||
>>> crl = wad.get_crl_data()
|
>>> crl = wad.get_crl_data()
|
||||||
>>>
|
>>>
|
||||||
@ -123,7 +131,7 @@ As mentioned earlier in this guide, WADs also contain up to three extra regions
|
|||||||
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.
|
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}
|
:::{note}
|
||||||
Managed to find a WAD somewhere with CRL data? I'd love to here more, so feel free to email me at [ninjacheetah@ncxprogramming.com](mailto:ninjacheetah@ncxprogramming.com).
|
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>
|
<hr>
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "libWiiPy"
|
name = "libWiiPy"
|
||||||
version = "0.5.1"
|
version = "0.6.1"
|
||||||
authors = [
|
authors = [
|
||||||
{ name="NinjaCheetah", email="ninjacheetah@ncxprogramming.com" },
|
{ name="NinjaCheetah", email="ninjacheetah@ncxprogramming.com" },
|
||||||
{ name="Lillian Skinner", email="lillian@randommeaninglesscharacters.com" }
|
{ name="Lillian Skinner", email="lillian@randommeaninglesscharacters.com" }
|
||||||
|
@ -3,8 +3,9 @@
|
|||||||
#
|
#
|
||||||
# These are the essential submodules 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.
|
||||||
|
|
||||||
__all__ = ["archive", "nand", "title"]
|
__all__ = ["archive", "media", "nand", "title"]
|
||||||
|
|
||||||
from . import archive
|
from . import archive
|
||||||
|
from . import media
|
||||||
from . import nand
|
from . import nand
|
||||||
from . import title
|
from . import title
|
||||||
|
@ -2,4 +2,5 @@
|
|||||||
# https://github.com/NinjaCheetah/libWiiPy
|
# https://github.com/NinjaCheetah/libWiiPy
|
||||||
|
|
||||||
from .ash import *
|
from .ash import *
|
||||||
|
from .lz77 import *
|
||||||
from .u8 import *
|
from .u8 import *
|
||||||
|
@ -5,7 +5,7 @@
|
|||||||
# co-authored by NinjaCheetah.
|
# co-authored by NinjaCheetah.
|
||||||
# https://github.com/NinjaCheetah/ASH0-tools
|
# https://github.com/NinjaCheetah/ASH0-tools
|
||||||
#
|
#
|
||||||
# See <link pending> for details about the ASH archive format.
|
# See <link pending> for details about the ASH compression format.
|
||||||
|
|
||||||
import io
|
import io
|
||||||
from dataclasses import dataclass as _dataclass
|
from dataclasses import dataclass as _dataclass
|
||||||
|
291
src/libWiiPy/archive/lz77.py
Normal file
291
src/libWiiPy/archive/lz77.py
Normal 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
|
@ -3,11 +3,14 @@
|
|||||||
#
|
#
|
||||||
# See https://wiibrew.org/wiki/U8_archive for details about the U8 archive format.
|
# See https://wiibrew.org/wiki/U8_archive for details about the U8 archive format.
|
||||||
|
|
||||||
|
import binascii
|
||||||
|
import hashlib
|
||||||
import io
|
import io
|
||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
|
from enum import IntEnum as _IntEnum
|
||||||
from dataclasses import dataclass as _dataclass
|
from dataclasses import dataclass as _dataclass
|
||||||
from typing import List
|
from typing import List, Tuple
|
||||||
from ..shared import _align_value, _pad_bytes
|
from ..shared import _align_value, _pad_bytes
|
||||||
|
|
||||||
|
|
||||||
@ -36,13 +39,25 @@ class _U8Node:
|
|||||||
|
|
||||||
|
|
||||||
class U8Archive:
|
class U8Archive:
|
||||||
def __init__(self):
|
"""
|
||||||
"""
|
A U8 object that allows for parsing and editing the contents of a U8 archive.
|
||||||
A U8 object that allows for managing the contents of a U8 archive.
|
|
||||||
|
|
||||||
Attributes
|
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_magic = b''
|
||||||
self.u8_node_list: List[_U8Node] = [] # All the nodes in the header of a U8 file.
|
self.u8_node_list: List[_U8Node] = [] # All the nodes in the header of a U8 file.
|
||||||
self.file_name_list: List[str] = []
|
self.file_name_list: List[str] = []
|
||||||
@ -51,6 +66,7 @@ class U8Archive:
|
|||||||
self.header_size: int = 0
|
self.header_size: int = 0
|
||||||
self.data_offset: int = 0
|
self.data_offset: int = 0
|
||||||
self.root_node: _U8Node = _U8Node(0, 0, 0, 0)
|
self.root_node: _U8Node = _U8Node(0, 0, 0, 0)
|
||||||
|
self.imet_header: IMETHeader = IMETHeader()
|
||||||
|
|
||||||
def load(self, u8_data: bytes) -> None:
|
def load(self, u8_data: bytes) -> None:
|
||||||
"""
|
"""
|
||||||
@ -66,7 +82,34 @@ class U8Archive:
|
|||||||
u8_data.seek(0x0)
|
u8_data.seek(0x0)
|
||||||
self.u8_magic = u8_data.read(4)
|
self.u8_magic = u8_data.read(4)
|
||||||
if self.u8_magic != b'\x55\xAA\x38\x2D':
|
if self.u8_magic != b'\x55\xAA\x38\x2D':
|
||||||
raise TypeError("This is not a valid U8 archive!")
|
# 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.
|
# Offset of the root node, which will always be 0x20.
|
||||||
self.root_node_offset = int.from_bytes(u8_data.read(4))
|
self.root_node_offset = int.from_bytes(u8_data.read(4))
|
||||||
# The size of the U8 header.
|
# The size of the U8 header.
|
||||||
@ -215,7 +258,7 @@ def extract_u8(u8_data, output_folder) -> None:
|
|||||||
open(current_dir.joinpath(u8_archive.file_name_list[node]), "wb").write(u8_archive.file_data_list[node])
|
open(current_dir.joinpath(u8_archive.file_name_list[node]), "wb").write(u8_archive.file_data_list[node])
|
||||||
# Handle an invalid node type.
|
# Handle an invalid node type.
|
||||||
elif u8_archive.u8_node_list[node].type != 0 and u8_archive.u8_node_list[node].type != 1:
|
elif u8_archive.u8_node_list[node].type != 0 and u8_archive.u8_node_list[node].type != 1:
|
||||||
raise ValueError("A node with an invalid type (" + str(u8_archive.u8_node_list[node].type) + ") was found!")
|
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):
|
def _pack_u8_dir(u8_archive: U8Archive, current_path, node_count, parent_node):
|
||||||
@ -257,14 +300,23 @@ def _pack_u8_dir(u8_archive: U8Archive, current_path, node_count, parent_node):
|
|||||||
return u8_archive, node_count
|
return u8_archive, node_count
|
||||||
|
|
||||||
|
|
||||||
def pack_u8(input_path) -> bytes:
|
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.
|
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
|
Parameters
|
||||||
----------
|
----------
|
||||||
input_path
|
input_path
|
||||||
The path to the input file or folder.
|
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
|
Returns
|
||||||
-------
|
-------
|
||||||
@ -283,8 +335,221 @@ def pack_u8(input_path) -> bytes:
|
|||||||
# subdirectory and file. Discard node_count and name_offset since we don't care about them here, as they're
|
# subdirectory and file. Discard node_count and name_offset since we don't care about them here, as they're
|
||||||
# really only necessary for the directory recursion.
|
# really only necessary for the directory recursion.
|
||||||
u8_archive, _ = _pack_u8_dir(u8_archive, input_path, node_count=1, parent_node=0)
|
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()
|
return u8_archive.dump()
|
||||||
elif input_path.is_file():
|
elif input_path.is_file():
|
||||||
raise ValueError("This does not appear to be a directory.")
|
raise ValueError("This does not appear to be a directory.")
|
||||||
else:
|
else:
|
||||||
raise FileNotFoundError("Input directory: \"" + str(input_path) + "\" does not exist!")
|
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]
|
||||||
|
4
src/libWiiPy/media/__init__.py
Normal file
4
src/libWiiPy/media/__init__.py
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
# "media/__init__.py" from libWiiPy by NinjaCheetah & Contributors
|
||||||
|
# https://github.com/NinjaCheetah/libWiiPy
|
||||||
|
|
||||||
|
from .banner import *
|
31
src/libWiiPy/media/banner.py
Normal file
31
src/libWiiPy/media/banner.py
Normal 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
|
@ -6,7 +6,11 @@
|
|||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
import shutil
|
import shutil
|
||||||
|
from dataclasses import dataclass as _dataclass
|
||||||
|
from typing import List
|
||||||
|
from ..title.ticket import Ticket
|
||||||
from ..title.title import Title
|
from ..title.title import Title
|
||||||
|
from ..title.tmd import TMD
|
||||||
from ..title.content import SharedContentMap as _SharedContentMap
|
from ..title.content import SharedContentMap as _SharedContentMap
|
||||||
from .sys import UidSys as _UidSys
|
from .sys import UidSys as _UidSys
|
||||||
|
|
||||||
@ -73,7 +77,7 @@ class EmuNAND:
|
|||||||
# Tickets are installed as <tid_lower>.tik in /ticket/<tid_upper>/
|
# Tickets are installed as <tid_lower>.tik in /ticket/<tid_upper>/
|
||||||
ticket_dir = self.ticket_dir.joinpath(tid_upper)
|
ticket_dir = self.ticket_dir.joinpath(tid_upper)
|
||||||
ticket_dir.mkdir(exist_ok=True)
|
ticket_dir.mkdir(exist_ok=True)
|
||||||
open(ticket_dir.joinpath(tid_lower + ".tik"), "wb").write(title.wad.get_ticket_data())
|
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
|
# 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.tmd and the contents being named <cid>.app.
|
||||||
@ -85,11 +89,11 @@ class EmuNAND:
|
|||||||
if content_dir.exists():
|
if content_dir.exists():
|
||||||
shutil.rmtree(content_dir) # Clear the content directory so old contents aren't left behind.
|
shutil.rmtree(content_dir) # Clear the content directory so old contents aren't left behind.
|
||||||
content_dir.mkdir(exist_ok=True)
|
content_dir.mkdir(exist_ok=True)
|
||||||
open(content_dir.joinpath("title.tmd"), "wb").write(title.wad.get_tmd_data())
|
content_dir.joinpath("title.tmd").write_bytes(title.tmd.dump())
|
||||||
for content_file in range(0, title.tmd.num_contents):
|
for content_file in range(0, title.tmd.num_contents):
|
||||||
if title.tmd.content_records[content_file].content_type == 1:
|
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_file_name = f"{title.tmd.content_records[content_file].content_id:08X}".lower()
|
||||||
open(content_dir.joinpath(content_file_name + ".app"), "wb").write(
|
content_dir.joinpath(f"{content_file_name}.app").write_bytes(
|
||||||
title.get_content_by_index(content_file, skip_hash=skip_hash))
|
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.
|
title_dir.joinpath("data").mkdir(exist_ok=True) # Empty directory used for save data for the title.
|
||||||
|
|
||||||
@ -98,16 +102,16 @@ class EmuNAND:
|
|||||||
content_map = _SharedContentMap()
|
content_map = _SharedContentMap()
|
||||||
existing_hashes = []
|
existing_hashes = []
|
||||||
if content_map_path.exists():
|
if content_map_path.exists():
|
||||||
content_map.load(open(content_map_path, "rb").read())
|
content_map.load(content_map_path.read_bytes())
|
||||||
for record in content_map.shared_records:
|
for record in content_map.shared_records:
|
||||||
existing_hashes.append(record.content_hash)
|
existing_hashes.append(record.content_hash)
|
||||||
for content_file in range(0, title.tmd.num_contents):
|
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_type == 32769:
|
||||||
if title.tmd.content_records[content_file].content_hash not in existing_hashes:
|
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)
|
content_file_name = content_map.add_content(title.tmd.content_records[content_file].content_hash)
|
||||||
open(self.shared1_dir.joinpath(content_file_name + ".app"), "wb").write(
|
self.shared1_dir.joinpath(f"{content_file_name}.app").write_bytes(
|
||||||
title.get_content_by_index(content_file, skip_hash=skip_hash))
|
title.get_content_by_index(content_file, skip_hash=skip_hash))
|
||||||
open(self.shared1_dir.joinpath("content.map"), "wb").write(content_map.dump())
|
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
|
# The "footer" or meta file is installed as title.met in /meta/<tid_upper>/<tid_lower>/. Only write this if meta
|
||||||
# is not nothing.
|
# is not nothing.
|
||||||
@ -117,13 +121,17 @@ class EmuNAND:
|
|||||||
meta_dir.mkdir(exist_ok=True)
|
meta_dir.mkdir(exist_ok=True)
|
||||||
meta_dir = meta_dir.joinpath(tid_lower)
|
meta_dir = meta_dir.joinpath(tid_lower)
|
||||||
meta_dir.mkdir(exist_ok=True)
|
meta_dir.mkdir(exist_ok=True)
|
||||||
open(meta_dir.joinpath("title.met"), "wb").write(title.wad.get_meta_data())
|
meta_dir.joinpath("title.met").write_bytes(title.wad.get_meta_data())
|
||||||
|
|
||||||
# Ensure we have a uid.sys file created.
|
# Ensure we have a uid.sys file created.
|
||||||
uid_sys_path = self.sys_dir.joinpath("uid.sys")
|
uid_sys_path = self.sys_dir.joinpath("uid.sys")
|
||||||
uid_sys = _UidSys()
|
uid_sys = _UidSys()
|
||||||
if not uid_sys_path.exists():
|
if not uid_sys_path.exists():
|
||||||
uid_sys.create()
|
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:
|
def uninstall_title(self, tid: str) -> None:
|
||||||
"""
|
"""
|
||||||
@ -159,3 +167,96 @@ class EmuNAND:
|
|||||||
# On the off chance this title has a meta entry, delete that too.
|
# 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():
|
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))
|
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
|
||||||
|
@ -77,7 +77,8 @@ class UidSys:
|
|||||||
|
|
||||||
def add(self, title_id: str | bytes) -> int:
|
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.
|
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
|
Parameters
|
||||||
----------
|
----------
|
||||||
@ -106,6 +107,11 @@ class UidSys:
|
|||||||
title_id_converted = title_id
|
title_id_converted = title_id
|
||||||
else:
|
else:
|
||||||
raise TypeError("Title ID type is not valid! It must be either type str or bytes.")
|
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.
|
# Generate the new UID by incrementing the current highest UID by 1.
|
||||||
try:
|
try:
|
||||||
new_uid = self.uid_entries[-1].uid + 1
|
new_uid = self.uid_entries[-1].uid + 1
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
# "title/__init__.py" from libWiiPy by NinjaCheetah & Contributors
|
# "title/__init__.py" from libWiiPy by NinjaCheetah & Contributors
|
||||||
# https://github.com/NinjaCheetah/libWiiPy
|
# https://github.com/NinjaCheetah/libWiiPy
|
||||||
|
|
||||||
|
from .cert import *
|
||||||
from .content import *
|
from .content import *
|
||||||
from .crypto import *
|
from .crypto import *
|
||||||
from .iospatcher import *
|
from .iospatcher import *
|
||||||
|
362
src/libWiiPy/title/cert.py
Normal file
362
src/libWiiPy/title/cert.py
Normal 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
|
@ -7,11 +7,14 @@ common_key = 'ebe42a225e8593e448d9c5457381aaf7'
|
|||||||
korean_key = '63b82bb4f4614e2e13f2fefbba4c9b7e'
|
korean_key = '63b82bb4f4614e2e13f2fefbba4c9b7e'
|
||||||
vwii_key = '30bfc76e7c19afbb23163330ced7c28d'
|
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. If an invalid common key index is provided, this
|
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).
|
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
|
Possible values for common_key_index: 0: Common Key, 1: Korean Key, 2: vWii Key
|
||||||
|
|
||||||
@ -19,6 +22,8 @@ def get_common_key(common_key_index) -> bytes:
|
|||||||
----------
|
----------
|
||||||
common_key_index : int
|
common_key_index : int
|
||||||
The index of the common key to be returned.
|
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
|
Returns
|
||||||
-------
|
-------
|
||||||
@ -27,7 +32,10 @@ def get_common_key(common_key_index) -> bytes:
|
|||||||
"""
|
"""
|
||||||
match common_key_index:
|
match common_key_index:
|
||||||
case 0:
|
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:
|
case 1:
|
||||||
common_key_bin = binascii.unhexlify(korean_key)
|
common_key_bin = binascii.unhexlify(korean_key)
|
||||||
case 2:
|
case 2:
|
||||||
|
@ -117,10 +117,6 @@ class ContentRegion:
|
|||||||
"""
|
"""
|
||||||
Gets an individual content from the content region based on the provided index, in encrypted form.
|
Gets an individual content from the content region based on the provided index, in encrypted form.
|
||||||
|
|
||||||
This uses the content index, which is the value tied to each content and used as the IV for encryption, rather
|
|
||||||
than the literal index in the array of content, because sometimes the contents end up out of order in a WAD
|
|
||||||
while still retaining the original indices.
|
|
||||||
|
|
||||||
Parameters
|
Parameters
|
||||||
----------
|
----------
|
||||||
index : int
|
index : int
|
||||||
@ -131,17 +127,10 @@ class ContentRegion:
|
|||||||
bytes
|
bytes
|
||||||
The encrypted content listed in the content record.
|
The encrypted content listed in the content record.
|
||||||
"""
|
"""
|
||||||
# Get a list of the current content indices, so we can make sure the target one exists. Doing it this way
|
if index >= self.num_contents:
|
||||||
# ensures we can find the target, even if the highest content index is greater than the highest literal index.
|
raise ValueError(f"You are trying to get the content at index {index}, but no content with that "
|
||||||
current_indices = []
|
f"index exists!")
|
||||||
for record in self.content_records:
|
content_enc = self.content_list[index]
|
||||||
current_indices.append(record.index)
|
|
||||||
if index not in current_indices:
|
|
||||||
raise ValueError("You are trying to get the content at index " + str(index) + ", but no content with that "
|
|
||||||
"index exists!")
|
|
||||||
# This is the literal index in the list of content that we're going to get.
|
|
||||||
target_index = current_indices.index(index)
|
|
||||||
content_enc = self.content_list[target_index]
|
|
||||||
return content_enc
|
return content_enc
|
||||||
|
|
||||||
def get_enc_content_by_cid(self, cid: int) -> bytes:
|
def get_enc_content_by_cid(self, cid: int) -> bytes:
|
||||||
@ -181,14 +170,10 @@ class ContentRegion:
|
|||||||
"""
|
"""
|
||||||
Gets an individual content from the content region based on the provided index, in decrypted form.
|
Gets an individual content from the content region based on the provided index, in decrypted form.
|
||||||
|
|
||||||
This uses the content index, which is the value tied to each content and used as the IV for encryption, rather
|
|
||||||
than the literal index in the array of content, because sometimes the contents end up out of order in a WAD
|
|
||||||
while still retaining the original indices.
|
|
||||||
|
|
||||||
Parameters
|
Parameters
|
||||||
----------
|
----------
|
||||||
index : int
|
index : int
|
||||||
The content index of the content you want to get.
|
The index of the content you want to get.
|
||||||
title_key : bytes
|
title_key : bytes
|
||||||
The Title Key for the title the content is from.
|
The Title Key for the title the content is from.
|
||||||
skip_hash : bool, optional
|
skip_hash : bool, optional
|
||||||
@ -199,19 +184,14 @@ class ContentRegion:
|
|||||||
bytes
|
bytes
|
||||||
The decrypted content listed in the content record.
|
The decrypted content listed in the content record.
|
||||||
"""
|
"""
|
||||||
# Get a list of the current content indices, so we can make sure the target one exists. Doing it this way
|
# Get the content index in the Content Record to ensure decryption works properly.
|
||||||
# ensures we can find the target, even if the highest content index is greater than the highest literal index.
|
cnt_index = self.content_records[index].index
|
||||||
current_indices = []
|
|
||||||
for record in self.content_records:
|
|
||||||
current_indices.append(record.index)
|
|
||||||
# This is the literal index in the list of content that we're going to get.
|
|
||||||
target_index = current_indices.index(index)
|
|
||||||
content_enc = self.get_enc_content_by_index(index)
|
content_enc = self.get_enc_content_by_index(index)
|
||||||
content_dec = decrypt_content(content_enc, title_key, index, self.content_records[target_index].content_size)
|
content_dec = decrypt_content(content_enc, title_key, cnt_index, self.content_records[index].content_size)
|
||||||
# Hash the decrypted content and ensure that the hash matches the one in its Content Record.
|
# Hash the decrypted content and ensure that the hash matches the one in its Content Record.
|
||||||
# If it does not, then something has gone wrong in the decryption, and an error will be thrown.
|
# If it does not, then something has gone wrong in the decryption, and an error will be thrown.
|
||||||
content_dec_hash = hashlib.sha1(content_dec).hexdigest()
|
content_dec_hash = hashlib.sha1(content_dec).hexdigest()
|
||||||
content_record_hash = str(self.content_records[target_index].content_hash.decode())
|
content_record_hash = str(self.content_records[index].content_hash.decode())
|
||||||
# Compare the hash and throw a ValueError if the hash doesn't match.
|
# Compare the hash and throw a ValueError if the hash doesn't match.
|
||||||
if content_dec_hash != content_record_hash:
|
if content_dec_hash != content_record_hash:
|
||||||
if skip_hash:
|
if skip_hash:
|
||||||
@ -273,9 +253,7 @@ class ContentRegion:
|
|||||||
|
|
||||||
def get_index_from_cid(self, cid: int) -> int:
|
def get_index_from_cid(self, cid: int) -> int:
|
||||||
"""
|
"""
|
||||||
Gets the content index of a content by its Content ID. The returned index is the value tied to each content and
|
Gets the index of a content by its Content ID.
|
||||||
used as the IV for encryption, rather than the literal index in the array of content, because sometimes the
|
|
||||||
contents end up out of order in a WAD while still retaining the original indices.
|
|
||||||
|
|
||||||
Parameters
|
Parameters
|
||||||
----------
|
----------
|
||||||
@ -293,9 +271,8 @@ class ContentRegion:
|
|||||||
content_ids.append(record.content_id)
|
content_ids.append(record.content_id)
|
||||||
if cid not in content_ids:
|
if cid not in content_ids:
|
||||||
raise ValueError("The specified Content ID does not exist!")
|
raise ValueError("The specified Content ID does not exist!")
|
||||||
literal_index = content_ids.index(cid)
|
index = content_ids.index(cid)
|
||||||
target_index = self.content_records[literal_index].index
|
return index
|
||||||
return target_index
|
|
||||||
|
|
||||||
def add_enc_content(self, enc_content: bytes, cid: int, index: int, content_type: int, content_size: int,
|
def add_enc_content(self, enc_content: bytes, cid: int, index: int, content_type: int, content_size: int,
|
||||||
content_hash: bytes) -> None:
|
content_hash: bytes) -> None:
|
||||||
@ -327,6 +304,7 @@ class ContentRegion:
|
|||||||
# If we're good, then append all the data and create a new ContentRecord().
|
# If we're good, then append all the data and create a new ContentRecord().
|
||||||
self.content_list.append(enc_content)
|
self.content_list.append(enc_content)
|
||||||
self.content_records.append(_ContentRecord(cid, index, content_type, content_size, content_hash))
|
self.content_records.append(_ContentRecord(cid, index, content_type, content_size, content_hash))
|
||||||
|
self.num_contents += 1
|
||||||
|
|
||||||
def add_content(self, dec_content: bytes, cid: int, content_type: int, title_key: bytes) -> None:
|
def add_content(self, dec_content: bytes, cid: int, content_type: int, title_key: bytes) -> None:
|
||||||
"""
|
"""
|
||||||
@ -363,18 +341,14 @@ class ContentRegion:
|
|||||||
"""
|
"""
|
||||||
Sets the content at the provided content index to the provided new encrypted content. The provided hash and
|
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
|
content size are set in the corresponding content record. A new Content ID or content type can also be
|
||||||
specified, but if it isn't than the current values are preserved.
|
specified, but if it isn't then the current values are preserved.
|
||||||
|
|
||||||
This uses the content index, which is the value tied to each content and used as the IV for encryption, rather
|
|
||||||
than the literal index in the array of content, because sometimes the contents end up out of order in a WAD
|
|
||||||
while still retaining the original indices.
|
|
||||||
|
|
||||||
Parameters
|
Parameters
|
||||||
----------
|
----------
|
||||||
enc_content : bytes
|
enc_content : bytes
|
||||||
The new encrypted content to set.
|
The new encrypted content to set.
|
||||||
index : int
|
index : int
|
||||||
The target content index to set the new content at.
|
The target index to set the new content at.
|
||||||
content_size : int
|
content_size : int
|
||||||
The size of the new encrypted content when decrypted.
|
The size of the new encrypted content when decrypted.
|
||||||
content_hash : bytes
|
content_hash : bytes
|
||||||
@ -384,34 +358,27 @@ class ContentRegion:
|
|||||||
content_type : int, optional
|
content_type : int, optional
|
||||||
The type of the new content. Current value will be preserved if not set.
|
The type of the new content. Current value will be preserved if not set.
|
||||||
"""
|
"""
|
||||||
# Get a list of the current content indices, so we can make sure the target one exists. Doing it this way
|
if index >= self.num_contents:
|
||||||
# ensures we can find the target, even if the highest content index is greater than the highest literal index.
|
raise ValueError(f"You are trying to set the content at index {index}, but no content with that "
|
||||||
current_indices = []
|
f"index currently exists!")
|
||||||
for record in self.content_records:
|
|
||||||
current_indices.append(record.index)
|
|
||||||
if index not in current_indices:
|
|
||||||
raise ValueError("You are trying to set the content at index " + str(index) + ", but no content with that "
|
|
||||||
"index currently exists!")
|
|
||||||
# This is the literal index in the list of content/content records that we're going to change.
|
|
||||||
target_index = current_indices.index(index)
|
|
||||||
# Reassign the values, but only set the optional ones if they were passed.
|
# Reassign the values, but only set the optional ones if they were passed.
|
||||||
self.content_records[target_index].content_size = content_size
|
self.content_records[index].content_size = content_size
|
||||||
self.content_records[target_index].content_hash = content_hash
|
self.content_records[index].content_hash = content_hash
|
||||||
if cid is not None:
|
if cid is not None:
|
||||||
self.content_records[target_index].content_id = cid
|
self.content_records[index].content_id = cid
|
||||||
if content_type is not None:
|
if content_type is not None:
|
||||||
self.content_records[target_index].content_type = content_type
|
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.
|
# 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):
|
while len(self.content_list) < len(self.content_records):
|
||||||
self.content_list.append(b'')
|
self.content_list.append(b'')
|
||||||
self.content_list[target_index] = enc_content
|
self.content_list[index] = enc_content
|
||||||
|
|
||||||
def set_content(self, dec_content: bytes, index: int, title_key: bytes, cid: int = None,
|
def set_content(self, dec_content: bytes, index: int, title_key: bytes, cid: int = None,
|
||||||
content_type: int = None) -> 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
|
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
|
of this content will be generated and then set in the corresponding content record. A new Content ID or content
|
||||||
type can also be specified, but if it isn't than the current values are preserved.
|
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.
|
The provided Title Key is used to encrypt the content so that it can be set in the ContentRegion.
|
||||||
|
|
||||||
@ -432,8 +399,9 @@ class ContentRegion:
|
|||||||
content_size = len(dec_content)
|
content_size = len(dec_content)
|
||||||
# Calculate the hash of the new content.
|
# Calculate the hash of the new content.
|
||||||
content_hash = str.encode(hashlib.sha1(dec_content).hexdigest())
|
content_hash = str.encode(hashlib.sha1(dec_content).hexdigest())
|
||||||
# Encrypt the content using the provided Title Key and index.
|
# Encrypt the content using the provided Title Key and the index from the Content Record, to ensure that
|
||||||
enc_content = encrypt_content(dec_content, title_key, index)
|
# 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()
|
# Pass values to set_enc_content()
|
||||||
self.set_enc_content(enc_content, index, content_size, content_hash, cid, content_type)
|
self.set_enc_content(enc_content, index, content_size, content_hash, cid, content_type)
|
||||||
|
|
||||||
@ -443,10 +411,6 @@ class ContentRegion:
|
|||||||
it matches the record at that index. Not recommended for most use cases, use decrypted content and
|
it matches the record at that index. Not recommended for most use cases, use decrypted content and
|
||||||
load_content() instead.
|
load_content() instead.
|
||||||
|
|
||||||
This uses the content index, which is the value tied to each content and used as the IV for encryption, rather
|
|
||||||
than the literal index in the array of content, because sometimes the contents end up out of order in a WAD
|
|
||||||
while still retaining the original indices.
|
|
||||||
|
|
||||||
Parameters
|
Parameters
|
||||||
----------
|
----------
|
||||||
enc_content : bytes
|
enc_content : bytes
|
||||||
@ -454,20 +418,13 @@ class ContentRegion:
|
|||||||
index : int
|
index : int
|
||||||
The content index to load the content at.
|
The content index to load the content at.
|
||||||
"""
|
"""
|
||||||
# Get a list of the current content indices, so we can make sure the target one exists. Doing it this way
|
if index >= self.num_contents:
|
||||||
# ensures we can find the target, even if the highest content index is greater than the highest literal index.
|
raise ValueError(f"You are trying to load the content at index {index}, but no content with that "
|
||||||
current_indices = []
|
f"index currently exists! Make sure the correct content records have been loaded.")
|
||||||
for record in self.content_records:
|
|
||||||
current_indices.append(record.index)
|
|
||||||
if index not in current_indices:
|
|
||||||
raise ValueError("You are trying to load the content at index " + str(index) + ", but no content with that "
|
|
||||||
"index currently exists! Make sure the correct content records have been loaded.")
|
|
||||||
# Add blank entries to the list to ensure that its length matches the length of the content record list.
|
# 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):
|
while len(self.content_list) < len(self.content_records):
|
||||||
self.content_list.append(b'')
|
self.content_list.append(b'')
|
||||||
# This is the literal index in the list of content/content records that we're going to change.
|
self.content_list[index] = enc_content
|
||||||
target_index = current_indices.index(index)
|
|
||||||
self.content_list[target_index] = enc_content
|
|
||||||
|
|
||||||
def load_content(self, dec_content: bytes, index: int, title_key: bytes) -> None:
|
def load_content(self, dec_content: bytes, index: int, title_key: bytes) -> None:
|
||||||
"""
|
"""
|
||||||
@ -475,32 +432,21 @@ class ContentRegion:
|
|||||||
sure that it matches the corresponding record. This content will then be encrypted using the provided Title Key
|
sure that it matches the corresponding record. This content will then be encrypted using the provided Title Key
|
||||||
before being loaded.
|
before being loaded.
|
||||||
|
|
||||||
This uses the content index, which is the value tied to each content and used as the IV for encryption, rather
|
|
||||||
than the literal index in the array of content, because sometimes the contents end up out of order in a WAD
|
|
||||||
while still retaining the original indices.
|
|
||||||
|
|
||||||
Parameters
|
Parameters
|
||||||
----------
|
----------
|
||||||
dec_content : bytes
|
dec_content : bytes
|
||||||
The decrypted content to load.
|
The decrypted content to load.
|
||||||
index : int
|
index : int
|
||||||
The content index to load the content at.
|
The index to load the content at.
|
||||||
title_key: bytes
|
title_key: bytes
|
||||||
The Title Key that matches the decrypted content.
|
The Title Key that matches the decrypted content.
|
||||||
"""
|
"""
|
||||||
# Get a list of the current content indices, so we can make sure the target one exists. Doing it this way
|
if index >= self.num_contents:
|
||||||
# ensures we can find the target, even if the highest content index is greater than the highest literal index.
|
raise ValueError(f"You are trying to load the content at index {index}, but no content with that "
|
||||||
current_indices = []
|
f"index currently exists! Make sure the correct content records have been loaded.")
|
||||||
for record in self.content_records:
|
|
||||||
current_indices.append(record.index)
|
|
||||||
if index not in current_indices:
|
|
||||||
raise ValueError("You are trying to load the content at index " + str(index) + ", but no content with that "
|
|
||||||
"index currently exists! Make sure the correct content records have been loaded.")
|
|
||||||
# This is the literal index in the list of content/content records that we're going to change.
|
|
||||||
target_index = current_indices.index(index)
|
|
||||||
# Check the hash of the content against the hash stored in the record to ensure it matches.
|
# Check the hash of the content against the hash stored in the record to ensure it matches.
|
||||||
content_hash = hashlib.sha1(dec_content).hexdigest()
|
content_hash = hashlib.sha1(dec_content).hexdigest()
|
||||||
if content_hash != self.content_records[target_index].content_hash.decode():
|
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"
|
raise ValueError("The decrypted content provided does not match the record at the provided index. \n"
|
||||||
"Expected hash is: {}\n".format(self.content_records[index].content_hash.decode()) +
|
"Expected hash is: {}\n".format(self.content_records[index].content_hash.decode()) +
|
||||||
"Actual hash is: {}".format(content_hash))
|
"Actual hash is: {}".format(content_hash))
|
||||||
@ -508,11 +454,10 @@ class ContentRegion:
|
|||||||
while len(self.content_list) < len(self.content_records):
|
while len(self.content_list) < len(self.content_records):
|
||||||
self.content_list.append(b'')
|
self.content_list.append(b'')
|
||||||
# If the hash matches, encrypt the content and set it where it belongs.
|
# If the hash matches, encrypt the content and set it where it belongs.
|
||||||
# This uses the index from the content records instead of just the index given, because there are some strange
|
# This uses the index from the content records instead of just the index given, because there are some poorly
|
||||||
# circumstances where the actual index in the array and the assigned content index don't match up, and this
|
# made custom WADs out there that don't have the contents in order, for whatever reason.
|
||||||
# needs to accommodate that. Seems to only apply to custom WADs ? (Like cIOS WADs?)
|
enc_content = encrypt_content(dec_content, title_key, self.content_records[index].index)
|
||||||
enc_content = encrypt_content(dec_content, title_key, index)
|
self.content_list[index] = enc_content
|
||||||
self.content_list[target_index] = enc_content
|
|
||||||
|
|
||||||
def remove_content_by_index(self, index: int) -> None:
|
def remove_content_by_index(self, index: int) -> None:
|
||||||
"""
|
"""
|
||||||
@ -525,19 +470,13 @@ class ContentRegion:
|
|||||||
index : int
|
index : int
|
||||||
The index of the content you want to remove.
|
The index of the content you want to remove.
|
||||||
"""
|
"""
|
||||||
# Get a list of the current content indices, so we can make sure the target one exists. Doing it this way
|
if index >= self.num_contents:
|
||||||
# ensures we can find the target, even if the highest content index is greater than the highest literal index.
|
raise ValueError(f"You are trying to remove the content at index {index}, but no content with "
|
||||||
current_indices = []
|
f"that index currently exists!")
|
||||||
for record in self.content_records:
|
|
||||||
current_indices.append(record.index)
|
|
||||||
if index not in current_indices:
|
|
||||||
raise ValueError("You are trying to remove the content at index " + str(index) + ", but no content with "
|
|
||||||
"that index currently exists!")
|
|
||||||
# This is the literal index in the list of content/content records that we're going to change.
|
|
||||||
target_index = current_indices.index(index)
|
|
||||||
# Delete the target index from both the content list and content records.
|
# Delete the target index from both the content list and content records.
|
||||||
self.content_list.pop(target_index)
|
self.content_list.pop(index)
|
||||||
self.content_records.pop(target_index)
|
self.content_records.pop(index)
|
||||||
|
self.num_contents -= 1
|
||||||
|
|
||||||
def remove_content_by_cid(self, cid: int) -> None:
|
def remove_content_by_cid(self, cid: int) -> None:
|
||||||
"""
|
"""
|
||||||
@ -551,11 +490,11 @@ class ContentRegion:
|
|||||||
The Content ID of the content you want to remove.
|
The Content ID of the content you want to remove.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
content_index = self.get_index_from_cid(cid)
|
index = self.get_index_from_cid(cid)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise ValueError(f"You are trying to remove content with Content ID {cid}, "
|
raise ValueError(f"You are trying to remove content with Content ID {cid}, "
|
||||||
f"but no content with that ID exists!")
|
f"but no content with that ID exists!")
|
||||||
self.remove_content_by_index(content_index)
|
self.remove_content_by_index(index)
|
||||||
|
|
||||||
|
|
||||||
@_dataclass
|
@_dataclass
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
# "title/crypto.py" from libWiiPy by NinjaCheetah & Contributors
|
# "title/crypto.py" from libWiiPy by NinjaCheetah & Contributors
|
||||||
# https://github.com/NinjaCheetah/libWiiPy
|
# https://github.com/NinjaCheetah/libWiiPy
|
||||||
|
|
||||||
import struct
|
|
||||||
import binascii
|
import binascii
|
||||||
|
import struct
|
||||||
from .commonkeys import get_common_key
|
from .commonkeys import get_common_key
|
||||||
from Crypto.Cipher import AES as _AES
|
from Crypto.Cipher import AES as _AES
|
||||||
|
|
||||||
@ -30,7 +30,7 @@ def _convert_tid_to_iv(title_id: str | bytes) -> bytes:
|
|||||||
return title_key_iv
|
return title_key_iv
|
||||||
|
|
||||||
|
|
||||||
def decrypt_title_key(title_key_enc: bytes, common_key_index: int, title_id: bytes | str) -> bytes:
|
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.
|
Gets the decrypted version of the encrypted Title Key provided.
|
||||||
|
|
||||||
@ -44,6 +44,8 @@ def decrypt_title_key(title_key_enc: bytes, common_key_index: int, title_id: byt
|
|||||||
The index of the common key used to encrypt the Title Key.
|
The index of the common key used to encrypt the Title Key.
|
||||||
title_id : bytes, str
|
title_id : bytes, str
|
||||||
The Title ID of the title that the key is for.
|
The Title ID of the title that the key is for.
|
||||||
|
dev : bool
|
||||||
|
Whether the Title Key is encrypted with the development key or not.
|
||||||
|
|
||||||
Returns
|
Returns
|
||||||
-------
|
-------
|
||||||
@ -51,7 +53,7 @@ def decrypt_title_key(title_key_enc: bytes, common_key_index: int, title_id: byt
|
|||||||
The decrypted Title Key.
|
The decrypted Title Key.
|
||||||
"""
|
"""
|
||||||
# Load the correct common key for the title.
|
# Load the correct common key for the title.
|
||||||
common_key = get_common_key(common_key_index)
|
common_key = get_common_key(common_key_index, dev)
|
||||||
# Convert the IV into the correct format based on the type provided.
|
# Convert the IV into the correct format based on the type provided.
|
||||||
title_key_iv = _convert_tid_to_iv(title_id)
|
title_key_iv = _convert_tid_to_iv(title_id)
|
||||||
# The IV will always be in the same format by this point, so add the last 8 bytes.
|
# The IV will always be in the same format by this point, so add the last 8 bytes.
|
||||||
@ -63,7 +65,7 @@ def decrypt_title_key(title_key_enc: bytes, common_key_index: int, title_id: byt
|
|||||||
return title_key
|
return title_key
|
||||||
|
|
||||||
|
|
||||||
def encrypt_title_key(title_key_dec: bytes, common_key_index: int, title_id: bytes | str) -> bytes:
|
def encrypt_title_key(title_key_dec: bytes, common_key_index: int, title_id: bytes | str, dev=False) -> bytes:
|
||||||
"""
|
"""
|
||||||
Encrypts the provided Title Key with the selected common key.
|
Encrypts the provided Title Key with the selected common key.
|
||||||
|
|
||||||
@ -77,6 +79,8 @@ def encrypt_title_key(title_key_dec: bytes, common_key_index: int, title_id: byt
|
|||||||
The index of the common key used to encrypt the Title Key.
|
The index of the common key used to encrypt the Title Key.
|
||||||
title_id : bytes, str
|
title_id : bytes, str
|
||||||
The Title ID of the title that the key is for.
|
The Title ID of the title that the key is for.
|
||||||
|
dev : bool
|
||||||
|
Whether the Title Key is encrypted with the development key or not.
|
||||||
|
|
||||||
Returns
|
Returns
|
||||||
-------
|
-------
|
||||||
@ -84,7 +88,7 @@ def encrypt_title_key(title_key_dec: bytes, common_key_index: int, title_id: byt
|
|||||||
An encrypted Title Key.
|
An encrypted Title Key.
|
||||||
"""
|
"""
|
||||||
# Load the correct common key for the title.
|
# Load the correct common key for the title.
|
||||||
common_key = get_common_key(common_key_index)
|
common_key = get_common_key(common_key_index, dev)
|
||||||
# Convert the IV into the correct format based on the type provided.
|
# Convert the IV into the correct format based on the type provided.
|
||||||
title_key_iv = _convert_tid_to_iv(title_id)
|
title_key_iv = _convert_tid_to_iv(title_id)
|
||||||
# The IV will always be in the same format by this point, so add the last 8 bytes.
|
# The IV will always be in the same format by this point, so add the last 8 bytes.
|
||||||
|
@ -4,8 +4,9 @@
|
|||||||
# See https://wiibrew.org/wiki/NUS for details about the NUS
|
# See https://wiibrew.org/wiki/NUS for details about the NUS
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
import hashlib
|
#import hashlib
|
||||||
from typing import List
|
from typing import List
|
||||||
|
#from urllib.parse import urlparse as _urlparse
|
||||||
from .title import Title
|
from .title import Title
|
||||||
from .tmd import TMD
|
from .tmd import TMD
|
||||||
from .ticket import Ticket
|
from .ticket import Ticket
|
||||||
@ -13,7 +14,8 @@ from .ticket import Ticket
|
|||||||
_nus_endpoint = ["http://nus.cdn.shop.wii.com/ccs/download/", "http://ccs.cdn.wup.shop.nintendo.net/ccs/download/"]
|
_nus_endpoint = ["http://nus.cdn.shop.wii.com/ccs/download/", "http://ccs.cdn.wup.shop.nintendo.net/ccs/download/"]
|
||||||
|
|
||||||
|
|
||||||
def download_title(title_id: str, title_version: int = None, wiiu_endpoint: bool = False) -> Title:
|
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
|
Download an entire title and all of its contents, then load the downloaded components into a Title object for
|
||||||
further use. This method is NOT recommended for general use, as it has absolutely no verbosity. It is instead
|
further use. This method is NOT recommended for general use, as it has absolutely no verbosity. It is instead
|
||||||
@ -23,10 +25,13 @@ def download_title(title_id: str, title_version: int = None, wiiu_endpoint: bool
|
|||||||
----------
|
----------
|
||||||
title_id : str
|
title_id : str
|
||||||
The Title ID of the title to download.
|
The Title ID of the title to download.
|
||||||
title_version : int, option
|
title_version : int, optional
|
||||||
The version of the title to download. Defaults to latest if not set.
|
The version of the title to download. Defaults to latest if not set.
|
||||||
wiiu_endpoint : bool, option
|
wiiu_endpoint : bool, optional
|
||||||
Whether the Wii U endpoint for the NUS should be used or not. This increases download speeds. Defaults to False.
|
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
|
Returns
|
||||||
-------
|
-------
|
||||||
@ -35,18 +40,19 @@ def download_title(title_id: str, title_version: int = None, wiiu_endpoint: bool
|
|||||||
"""
|
"""
|
||||||
# First, create the new title.
|
# First, create the new title.
|
||||||
title = Title()
|
title = Title()
|
||||||
# Download and load the TMD, Ticket, and certs.
|
# Download and load the certificate chain, TMD, and Ticket.
|
||||||
title.load_tmd(download_tmd(title_id, title_version, wiiu_endpoint))
|
title.load_cert_chain(download_cert_chain(wiiu_endpoint, endpoint_override))
|
||||||
title.load_ticket(download_ticket(title_id, wiiu_endpoint))
|
title.load_tmd(download_tmd(title_id, title_version, wiiu_endpoint, endpoint_override))
|
||||||
title.wad.set_cert_data(download_cert(wiiu_endpoint))
|
title.load_ticket(download_ticket(title_id, wiiu_endpoint, endpoint_override))
|
||||||
# Download all contents
|
# Download all contents
|
||||||
title.load_content_records()
|
title.load_content_records()
|
||||||
title.content.content_list = download_contents(title_id, title.tmd, wiiu_endpoint)
|
title.content.content_list = download_contents(title_id, title.tmd, wiiu_endpoint, endpoint_override)
|
||||||
# Return the completed title.
|
# Return the completed title.
|
||||||
return title
|
return title
|
||||||
|
|
||||||
|
|
||||||
def download_tmd(title_id: str, title_version: int = None, wiiu_endpoint: bool = False) -> bytes:
|
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
|
Downloads the TMD of the Title specified in the object. Will download the latest version by default, or another
|
||||||
version if it was manually specified in the object.
|
version if it was manually specified in the object.
|
||||||
@ -59,6 +65,9 @@ def download_tmd(title_id: str, title_version: int = None, wiiu_endpoint: bool =
|
|||||||
The version of the TMD to download. Defaults to latest if not set.
|
The version of the TMD to download. Defaults to latest if not set.
|
||||||
wiiu_endpoint : bool, option
|
wiiu_endpoint : bool, option
|
||||||
Whether the Wii U endpoint for the NUS should be used or not. This increases download speeds. Defaults to False.
|
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
|
Returns
|
||||||
-------
|
-------
|
||||||
@ -67,15 +76,26 @@ def download_tmd(title_id: str, title_version: int = None, wiiu_endpoint: bool =
|
|||||||
"""
|
"""
|
||||||
# Build the download URL. The structure is download/<TID>/tmd for latest and download/<TID>/tmd.<version> for
|
# Build the download URL. The structure is download/<TID>/tmd for latest and download/<TID>/tmd.<version> for
|
||||||
# when a specific version is requested.
|
# when a specific version is requested.
|
||||||
if wiiu_endpoint is False:
|
if endpoint_override is not None:
|
||||||
tmd_url = _nus_endpoint[0] + title_id + "/tmd"
|
endpoint_url = _validate_endpoint(endpoint_override)
|
||||||
else:
|
else:
|
||||||
tmd_url = _nus_endpoint[1] + title_id + "/tmd"
|
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.
|
# Add the version to the URL if one was specified.
|
||||||
if title_version is not None:
|
if title_version is not None:
|
||||||
tmd_url += "." + str(title_version)
|
tmd_url += "." + str(title_version)
|
||||||
# Make the request.
|
# Make the request.
|
||||||
tmd_request = requests.get(url=tmd_url, headers={'User-Agent': 'wii libnup/1.0'}, stream=True)
|
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.
|
# Handle a 404 if the TID/version doesn't exist.
|
||||||
if tmd_request.status_code != 200:
|
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"
|
raise ValueError("The requested Title ID or TMD version does not exist. Please check the Title ID and Title"
|
||||||
@ -89,7 +109,7 @@ def download_tmd(title_id: str, title_version: int = None, wiiu_endpoint: bool =
|
|||||||
return tmd
|
return tmd
|
||||||
|
|
||||||
|
|
||||||
def download_ticket(title_id: str, wiiu_endpoint: bool = False) -> bytes:
|
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
|
Downloads the Ticket of the Title specified in the object. This will only work if the Title ID specified is for
|
||||||
a free title.
|
a free title.
|
||||||
@ -100,6 +120,9 @@ def download_ticket(title_id: str, wiiu_endpoint: bool = False) -> bytes:
|
|||||||
The Title ID of the title to download the Ticket for.
|
The Title ID of the title to download the Ticket for.
|
||||||
wiiu_endpoint : bool, option
|
wiiu_endpoint : bool, option
|
||||||
Whether the Wii U endpoint for the NUS should be used or not. This increases download speeds. Defaults to False.
|
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
|
Returns
|
||||||
-------
|
-------
|
||||||
@ -108,12 +131,23 @@ def download_ticket(title_id: str, wiiu_endpoint: bool = False) -> bytes:
|
|||||||
"""
|
"""
|
||||||
# Build the download URL. The structure is download/<TID>/cetk, and cetk will only exist if this is a free
|
# Build the download URL. The structure is download/<TID>/cetk, and cetk will only exist if this is a free
|
||||||
# title.
|
# title.
|
||||||
if wiiu_endpoint is False:
|
if endpoint_override is not None:
|
||||||
ticket_url = _nus_endpoint[0] + title_id + "/cetk"
|
endpoint_url = _validate_endpoint(endpoint_override)
|
||||||
else:
|
else:
|
||||||
ticket_url = _nus_endpoint[1] + title_id + "/cetk"
|
if wiiu_endpoint:
|
||||||
|
endpoint_url = _nus_endpoint[1]
|
||||||
|
else:
|
||||||
|
endpoint_url = _nus_endpoint[0]
|
||||||
|
ticket_url = endpoint_url + title_id + "/cetk"
|
||||||
# Make the request.
|
# Make the request.
|
||||||
ticket_request = requests.get(url=ticket_url, headers={'User-Agent': 'wii libnup/1.0'}, stream=True)
|
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:
|
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"
|
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.")
|
" be downloaded for titles that are free on the NUS.")
|
||||||
@ -126,44 +160,59 @@ def download_ticket(title_id: str, wiiu_endpoint: bool = False) -> bytes:
|
|||||||
return ticket
|
return ticket
|
||||||
|
|
||||||
|
|
||||||
def download_cert(wiiu_endpoint: bool = False) -> bytes:
|
def download_cert_chain(wiiu_endpoint: bool = False, endpoint_override: str = None) -> bytes:
|
||||||
"""
|
"""
|
||||||
Downloads the signing certificate used by all WADs. This uses System Menu 4.3U as the source.
|
Downloads the signing certificate chain used by all WADs. This uses System Menu 4.3U as the source.
|
||||||
|
|
||||||
Parameters
|
Parameters
|
||||||
----------
|
----------
|
||||||
wiiu_endpoint : bool, option
|
wiiu_endpoint : bool, option
|
||||||
Whether the Wii U endpoint for the NUS should be used or not. This increases download speeds. Defaults to False.
|
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
|
Returns
|
||||||
-------
|
-------
|
||||||
bytes
|
bytes
|
||||||
The cert file.
|
The cert file.
|
||||||
"""
|
"""
|
||||||
# Download the TMD and cetk for the System Menu 4.3U.
|
# Download the TMD and cetk for System Menu 4.3U (v513).
|
||||||
if wiiu_endpoint is False:
|
if endpoint_override is not None:
|
||||||
tmd_url = _nus_endpoint[0] + "0000000100000002/tmd.513"
|
endpoint_url = _validate_endpoint(endpoint_override)
|
||||||
cetk_url = _nus_endpoint[0] + "0000000100000002/cetk"
|
|
||||||
else:
|
else:
|
||||||
tmd_url = _nus_endpoint[1] + "0000000100000002/tmd.513"
|
if wiiu_endpoint:
|
||||||
cetk_url = _nus_endpoint[1] + "0000000100000002/cetk"
|
endpoint_url = _nus_endpoint[1]
|
||||||
tmd = requests.get(url=tmd_url, headers={'User-Agent': 'wii libnup/1.0'}, stream=True).content
|
else:
|
||||||
cetk = requests.get(url=cetk_url, headers={'User-Agent': 'wii libnup/1.0'}, stream=True).content
|
endpoint_url = _nus_endpoint[0]
|
||||||
# Assemble the certificate.
|
tmd_url = endpoint_url + "0000000100000002/tmd.513"
|
||||||
cert = b''
|
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.
|
# Certificate Authority data.
|
||||||
cert += cetk[0x2A4 + 768:]
|
cert_chain += cetk[0x2A4 + 768:]
|
||||||
# Certificate Policy data.
|
# Certificate Policy (TMD certificate) data.
|
||||||
cert += tmd[0x328:0x328 + 768]
|
cert_chain += tmd[0x328:0x328 + 768]
|
||||||
# XS data.
|
# XS (Ticket certificate) data.
|
||||||
cert += cetk[0x2A4:0x2A4 + 768]
|
cert_chain += cetk[0x2A4:0x2A4 + 768]
|
||||||
# Since the cert is always the same, check the hash to make sure nothing went wildly wrong.
|
# Since the cert chain is always the same, check the hash to make sure nothing went wildly wrong.
|
||||||
if hashlib.sha1(cert).hexdigest() != "ace0f15d2a851c383fe4657afc3840d6ffe30ad0":
|
# This is currently disabled because of the possibility that one may be downloading non-retail certs (gasp!).
|
||||||
raise Exception("An unknown error has occurred downloading and creating the certificate.")
|
#if hashlib.sha1(cert_chain).hexdigest() != "ace0f15d2a851c383fe4657afc3840d6ffe30ad0":
|
||||||
return cert
|
# 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) -> bytes:
|
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.
|
Downloads a specified content for the title specified in the object.
|
||||||
|
|
||||||
@ -175,6 +224,9 @@ def download_content(title_id: str, content_id: int, wiiu_endpoint: bool = False
|
|||||||
The Content ID of the content you wish to download.
|
The Content ID of the content you wish to download.
|
||||||
wiiu_endpoint : bool, option
|
wiiu_endpoint : bool, option
|
||||||
Whether the Wii U endpoint for the NUS should be used or not. This increases download speeds. Defaults to False.
|
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
|
Returns
|
||||||
-------
|
-------
|
||||||
@ -185,12 +237,23 @@ def download_content(title_id: str, content_id: int, wiiu_endpoint: bool = False
|
|||||||
content_id_hex = hex(content_id)[2:]
|
content_id_hex = hex(content_id)[2:]
|
||||||
if len(content_id_hex) < 2:
|
if len(content_id_hex) < 2:
|
||||||
content_id_hex = "0" + content_id_hex
|
content_id_hex = "0" + content_id_hex
|
||||||
if wiiu_endpoint is False:
|
if endpoint_override is not None:
|
||||||
content_url = _nus_endpoint[0] + title_id + "/000000" + content_id_hex
|
endpoint_url = _validate_endpoint(endpoint_override)
|
||||||
else:
|
else:
|
||||||
content_url = _nus_endpoint[1] + title_id + "/000000" + content_id_hex
|
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.
|
# Make the request.
|
||||||
content_request = requests.get(url=content_url, headers={'User-Agent': 'wii libnup/1.0'}, stream=True)
|
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:
|
if content_request.status_code != 200:
|
||||||
raise ValueError("The requested Title ID does not exist, or an invalid Content ID is present in the"
|
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 records provided.\n Failed while downloading Content ID: 000000" +
|
||||||
@ -199,7 +262,8 @@ def download_content(title_id: str, content_id: int, wiiu_endpoint: bool = False
|
|||||||
return content_data
|
return content_data
|
||||||
|
|
||||||
|
|
||||||
def download_contents(title_id: str, tmd: TMD, wiiu_endpoint: bool = False) -> List[bytes]:
|
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
|
Downloads all the contents for the title specified in the object. This requires a TMD to already be available
|
||||||
so that the content records can be accessed.
|
so that the content records can be accessed.
|
||||||
@ -212,6 +276,9 @@ def download_contents(title_id: str, tmd: TMD, wiiu_endpoint: bool = False) -> L
|
|||||||
The TMD that matches the title that the contents being downloaded are from.
|
The TMD that matches the title that the contents being downloaded are from.
|
||||||
wiiu_endpoint : bool, option
|
wiiu_endpoint : bool, option
|
||||||
Whether the Wii U endpoint for the NUS should be used or not. This increases download speeds. Defaults to False.
|
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
|
Returns
|
||||||
-------
|
-------
|
||||||
@ -228,6 +295,30 @@ def download_contents(title_id: str, tmd: TMD, wiiu_endpoint: bool = False) -> L
|
|||||||
content_list = []
|
content_list = []
|
||||||
for content_id in content_ids:
|
for content_id in content_ids:
|
||||||
# Call self.download_content() for each Content ID.
|
# Call self.download_content() for each Content ID.
|
||||||
content = download_content(title_id, content_id, wiiu_endpoint)
|
content = download_content(title_id, content_id, wiiu_endpoint, endpoint_override)
|
||||||
content_list.append(content)
|
content_list.append(content)
|
||||||
return content_list
|
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
|
||||||
|
@ -23,12 +23,11 @@ class _TitleLimit:
|
|||||||
Attributes
|
Attributes
|
||||||
----------
|
----------
|
||||||
limit_type : int
|
limit_type : int
|
||||||
The type of play limit applied.
|
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
|
maximum_usage : int
|
||||||
The maximum value for the type of play limit applied.
|
The maximum value for the type of play limit applied.
|
||||||
"""
|
"""
|
||||||
# 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
|
limit_type: int
|
||||||
# The maximum value of the limit applied.
|
# The maximum value of the limit applied.
|
||||||
maximum_usage: int
|
maximum_usage: int
|
||||||
@ -40,6 +39,9 @@ class Ticket:
|
|||||||
|
|
||||||
Attributes
|
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
|
signature : bytes
|
||||||
The signature applied to the ticket.
|
The signature applied to the ticket.
|
||||||
ticket_version : int
|
ticket_version : int
|
||||||
@ -56,6 +58,8 @@ class Ticket:
|
|||||||
The index of the common key required to decrypt this ticket's Title Key.
|
The index of the common key required to decrypt this ticket's Title Key.
|
||||||
"""
|
"""
|
||||||
def __init__(self):
|
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
|
# Signature blob header
|
||||||
self.signature_type: bytes = b'' # Type of signature, always 0x10001 for RSA-2048
|
self.signature_type: bytes = b'' # Type of signature, always 0x10001 for RSA-2048
|
||||||
self.signature: bytes = b'' # Actual signature data
|
self.signature: bytes = b'' # Actual signature data
|
||||||
@ -71,7 +75,7 @@ class Ticket:
|
|||||||
self.title_version: int = 0 # Version of the ticket's associated title.
|
self.title_version: int = 0 # Version of the ticket's associated title.
|
||||||
self.permitted_titles: bytes = b'' # Permitted titles mask
|
self.permitted_titles: bytes = b'' # Permitted titles mask
|
||||||
# "Permit mask. The current disc title is ANDed with the inverse of this mask to see if the result matches the
|
# "Permit mask. The current disc title is ANDed with the inverse of this mask to see if the result matches the
|
||||||
# Permitted Titles Mask."
|
# Permitted Titles Mask." -WiiBrew
|
||||||
self.permit_mask: bytes = b''
|
self.permit_mask: bytes = b''
|
||||||
self.title_export_allowed: int = 0 # Whether title export is allowed with a PRNG key or not.
|
self.title_export_allowed: int = 0 # Whether title export is allowed with a PRNG key or not.
|
||||||
self.common_key_index: int = 0 # Which common key should be used. 0 = Common Key, 1 = Korean Key, 2 = vWii Key
|
self.common_key_index: int = 0 # Which common key should be used. 0 = Common Key, 1 = Korean Key, 2 = vWii Key
|
||||||
@ -103,7 +107,7 @@ class Ticket:
|
|||||||
self.signature = ticket_data.read(256)
|
self.signature = ticket_data.read(256)
|
||||||
# Signature issuer.
|
# Signature issuer.
|
||||||
ticket_data.seek(0x140)
|
ticket_data.seek(0x140)
|
||||||
self.signature_issuer = str(ticket_data.read(64).decode())
|
self.signature_issuer = str(ticket_data.read(64).replace(b'\x00', b'').decode())
|
||||||
# ECDH data.
|
# ECDH data.
|
||||||
ticket_data.seek(0x180)
|
ticket_data.seek(0x180)
|
||||||
self.ecdh_data = ticket_data.read(60)
|
self.ecdh_data = ticket_data.read(60)
|
||||||
@ -124,7 +128,7 @@ class Ticket:
|
|||||||
self.console_id = int.from_bytes(ticket_data.read(4))
|
self.console_id = int.from_bytes(ticket_data.read(4))
|
||||||
# Title ID.
|
# Title ID.
|
||||||
ticket_data.seek(0x1DC)
|
ticket_data.seek(0x1DC)
|
||||||
self.title_id = binascii.hexlify(ticket_data.read(8))
|
self.title_id = ticket_data.read(8)
|
||||||
# Unknown data 1.
|
# Unknown data 1.
|
||||||
ticket_data.seek(0x1E4)
|
ticket_data.seek(0x1E4)
|
||||||
self.unknown1 = ticket_data.read(2)
|
self.unknown1 = ticket_data.read(2)
|
||||||
@ -155,6 +159,12 @@ class Ticket:
|
|||||||
limit_type = int.from_bytes(ticket_data.read(4))
|
limit_type = int.from_bytes(ticket_data.read(4))
|
||||||
limit_value = int.from_bytes(ticket_data.read(4))
|
limit_value = int.from_bytes(ticket_data.read(4))
|
||||||
self.title_limits_list.append(_TitleLimit(limit_type, limit_value))
|
self.title_limits_list.append(_TitleLimit(limit_type, limit_value))
|
||||||
|
# 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:
|
def dump(self) -> bytes:
|
||||||
"""
|
"""
|
||||||
@ -173,7 +183,10 @@ class Ticket:
|
|||||||
# Padding to 64 bytes.
|
# Padding to 64 bytes.
|
||||||
ticket_data += b'\x00' * 60
|
ticket_data += b'\x00' * 60
|
||||||
# Signature issuer.
|
# Signature issuer.
|
||||||
ticket_data += str.encode(self.signature_issuer)
|
signature_issuer = self.signature_issuer.encode()
|
||||||
|
while len(signature_issuer) < 0x40:
|
||||||
|
signature_issuer += b'\x00'
|
||||||
|
ticket_data += signature_issuer
|
||||||
# ECDH data.
|
# ECDH data.
|
||||||
ticket_data += self.ecdh_data
|
ticket_data += self.ecdh_data
|
||||||
# Ticket version.
|
# Ticket version.
|
||||||
@ -189,7 +202,7 @@ class Ticket:
|
|||||||
# Console ID.
|
# Console ID.
|
||||||
ticket_data += int.to_bytes(self.console_id, 4)
|
ticket_data += int.to_bytes(self.console_id, 4)
|
||||||
# Title ID.
|
# Title ID.
|
||||||
ticket_data += binascii.unhexlify(self.title_id)
|
ticket_data += self.title_id
|
||||||
# Unknown data 1.
|
# Unknown data 1.
|
||||||
ticket_data += self.unknown1
|
ticket_data += self.unknown1
|
||||||
# Title version.
|
# Title version.
|
||||||
@ -305,6 +318,8 @@ class Ticket:
|
|||||||
return "Korean"
|
return "Korean"
|
||||||
case 2:
|
case 2:
|
||||||
return "vWii"
|
return "vWii"
|
||||||
|
case _:
|
||||||
|
return "Unknown"
|
||||||
|
|
||||||
def get_title_key(self) -> bytes:
|
def get_title_key(self) -> bytes:
|
||||||
"""
|
"""
|
||||||
@ -315,7 +330,7 @@ class Ticket:
|
|||||||
bytes
|
bytes
|
||||||
The decrypted title key.
|
The decrypted title key.
|
||||||
"""
|
"""
|
||||||
title_key = decrypt_title_key(self.title_key_enc, self.common_key_index, self.title_id)
|
title_key = decrypt_title_key(self.title_key_enc, self.common_key_index, self.title_id, self.is_dev)
|
||||||
return title_key
|
return title_key
|
||||||
|
|
||||||
def set_title_id(self, title_id) -> None:
|
def set_title_id(self, title_id) -> None:
|
||||||
@ -330,7 +345,7 @@ class Ticket:
|
|||||||
"""
|
"""
|
||||||
if len(title_id) != 16:
|
if len(title_id) != 16:
|
||||||
raise ValueError("Invalid Title ID! Title IDs must be 8 bytes long.")
|
raise ValueError("Invalid Title ID! Title IDs must be 8 bytes long.")
|
||||||
self.title_id = title_id.encode()
|
self.title_id = binascii.unhexlify(title_id.encode())
|
||||||
|
|
||||||
def set_title_version(self, new_version: str | int) -> None:
|
def set_title_version(self, new_version: str | int) -> None:
|
||||||
"""
|
"""
|
||||||
|
@ -4,10 +4,13 @@
|
|||||||
# See https://wiibrew.org/wiki/Title for details about how titles are formatted
|
# See https://wiibrew.org/wiki/Title for details about how titles are formatted
|
||||||
|
|
||||||
import math
|
import math
|
||||||
from .content import ContentRegion
|
from .cert import (CertificateChain as _CertificateChain,
|
||||||
from .ticket import Ticket
|
verify_ca_cert as _verify_ca_cert, verify_cert_sig as _verify_cert_sig,
|
||||||
from .tmd import TMD
|
verify_tmd_sig as _verify_tmd_sig, verify_ticket_sig as _verify_ticket_sig)
|
||||||
from .wad import WAD
|
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
|
from .crypto import encrypt_title_key
|
||||||
|
|
||||||
|
|
||||||
@ -19,20 +22,23 @@ class Title:
|
|||||||
|
|
||||||
Attributes
|
Attributes
|
||||||
----------
|
----------
|
||||||
wad : WAD
|
wad: WAD
|
||||||
A WAD object of a WAD containing the title's data.
|
A WAD object of a WAD containing the title's data.
|
||||||
tmd : TMD
|
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.
|
A TMD object of the title's TMD.
|
||||||
ticket : Ticket
|
ticket: Ticket
|
||||||
A Ticket object of the title's Ticket.
|
A Ticket object of the title's Ticket.
|
||||||
content: ContentRegion
|
content: ContentRegion
|
||||||
A ContentRegion object containing the title's contents.
|
A ContentRegion object containing the title's contents.
|
||||||
"""
|
"""
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.wad: WAD = WAD()
|
self.wad: _WAD = _WAD()
|
||||||
self.tmd: TMD = TMD()
|
self.cert_chain: _CertificateChain = _CertificateChain()
|
||||||
self.ticket: Ticket = Ticket()
|
self.tmd: _TMD = _TMD()
|
||||||
self.content: ContentRegion = ContentRegion()
|
self.ticket: _Ticket = _Ticket()
|
||||||
|
self.content: _ContentRegion = _ContentRegion()
|
||||||
|
|
||||||
def load_wad(self, wad: bytes) -> None:
|
def load_wad(self, wad: bytes) -> None:
|
||||||
"""
|
"""
|
||||||
@ -45,22 +51,25 @@ class Title:
|
|||||||
The data for the WAD you wish to load.
|
The data for the WAD you wish to load.
|
||||||
"""
|
"""
|
||||||
# Create a new WAD object based on the WAD data provided.
|
# Create a new WAD object based on the WAD data provided.
|
||||||
self.wad = WAD()
|
self.wad = _WAD()
|
||||||
self.wad.load(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.
|
# Load the TMD.
|
||||||
self.tmd = TMD()
|
self.tmd = _TMD()
|
||||||
self.tmd.load(self.wad.get_tmd_data())
|
self.tmd.load(self.wad.get_tmd_data())
|
||||||
# Load the ticket.
|
# Load the ticket.
|
||||||
self.ticket = Ticket()
|
self.ticket = _Ticket()
|
||||||
self.ticket.load(self.wad.get_ticket_data())
|
self.ticket.load(self.wad.get_ticket_data())
|
||||||
# Load the content.
|
# Load the content.
|
||||||
self.content = ContentRegion()
|
self.content = _ContentRegion()
|
||||||
self.content.load(self.wad.get_content_data(), self.tmd.content_records)
|
self.content.load(self.wad.get_content_data(), self.tmd.content_records)
|
||||||
# Ensure that the Title IDs of the TMD and Ticket match before doing anything else. If they don't, throw an
|
# Ensure that the Title IDs of the TMD and Ticket match before doing anything else. If they don't, throw an
|
||||||
# error because clearly something strange has gone on with the WAD and editing it probably won't work.
|
# error because clearly something strange has gone on with the WAD and editing it probably won't work.
|
||||||
if self.tmd.title_id != str(self.ticket.title_id.decode()):
|
#if self.tmd.title_id != str(self.ticket.title_id.decode()):
|
||||||
raise ValueError("The Title IDs of the TMD and Ticket in this WAD do not match. This WAD appears to be "
|
# raise ValueError("The Title IDs of the TMD and Ticket in this WAD do not match. This WAD appears to be "
|
||||||
"invalid.")
|
# "invalid.")
|
||||||
|
|
||||||
def dump_wad(self) -> bytes:
|
def dump_wad(self) -> bytes:
|
||||||
"""
|
"""
|
||||||
@ -75,9 +84,11 @@ class Title:
|
|||||||
# Set WAD type to ib if the title being packed is boot2.
|
# Set WAD type to ib if the title being packed is boot2.
|
||||||
if self.tmd.title_id == "0000000100000001":
|
if self.tmd.title_id == "0000000100000001":
|
||||||
self.wad.wad_type = "ib"
|
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.
|
# Dump the TMD and set it in the WAD.
|
||||||
# This requires updating the content records and number of contents in the TMD first.
|
# This requires updating the content records and number of contents in the TMD first.
|
||||||
self.tmd.content_records = self.content.content_records
|
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.tmd.num_contents = len(self.content.content_records)
|
||||||
self.wad.set_tmd_data(self.tmd.dump())
|
self.wad.set_tmd_data(self.tmd.dump())
|
||||||
# Dump the Ticket and set it in the WAD.
|
# Dump the Ticket and set it in the WAD.
|
||||||
@ -87,6 +98,19 @@ class Title:
|
|||||||
self.wad.set_content_data(content_data, content_size)
|
self.wad.set_content_data(content_data, content_size)
|
||||||
return self.wad.dump()
|
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:
|
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.
|
Load existing TMD data into the title. Note that this will overwrite any existing TMD data for this title.
|
||||||
@ -94,9 +118,8 @@ class Title:
|
|||||||
Parameters
|
Parameters
|
||||||
----------
|
----------
|
||||||
tmd : bytes
|
tmd : bytes
|
||||||
The data for the WAD you wish to load.
|
The data for the TMD to load.
|
||||||
"""
|
"""
|
||||||
# Load TMD.
|
|
||||||
self.tmd.load(tmd)
|
self.tmd.load(tmd)
|
||||||
|
|
||||||
def load_ticket(self, ticket: bytes) -> None:
|
def load_ticket(self, ticket: bytes) -> None:
|
||||||
@ -107,9 +130,8 @@ class Title:
|
|||||||
Parameters
|
Parameters
|
||||||
----------
|
----------
|
||||||
ticket : bytes
|
ticket : bytes
|
||||||
The data for the WAD you wish to load.
|
The data for the Ticket to load.
|
||||||
"""
|
"""
|
||||||
# Load Ticket.
|
|
||||||
self.ticket.load(ticket)
|
self.ticket.load(ticket)
|
||||||
|
|
||||||
def load_content_records(self) -> None:
|
def load_content_records(self) -> None:
|
||||||
@ -119,8 +141,9 @@ class Title:
|
|||||||
"""
|
"""
|
||||||
if not self.tmd.content_records:
|
if not self.tmd.content_records:
|
||||||
ValueError("No TMD appears to have been loaded, so content records cannot be read from it.")
|
ValueError("No TMD appears to have been loaded, so content records cannot be read from it.")
|
||||||
# Load the content records into the ContentRegion object.
|
# Load the content records into the ContentRegion object, and update the number of contents.
|
||||||
self.content.content_records = self.tmd.content_records
|
self.content.content_records = self.tmd.content_records
|
||||||
|
self.content.num_contents = self.tmd.num_contents
|
||||||
|
|
||||||
def set_title_id(self, title_id: str) -> None:
|
def set_title_id(self, title_id: str) -> None:
|
||||||
"""
|
"""
|
||||||
@ -137,7 +160,8 @@ class Title:
|
|||||||
self.tmd.set_title_id(title_id)
|
self.tmd.set_title_id(title_id)
|
||||||
title_key_decrypted = self.ticket.get_title_key()
|
title_key_decrypted = self.ticket.get_title_key()
|
||||||
self.ticket.set_title_id(title_id)
|
self.ticket.set_title_id(title_id)
|
||||||
title_key_encrypted = encrypt_title_key(title_key_decrypted, self.ticket.common_key_index, title_id)
|
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
|
self.ticket.title_key_enc = title_key_encrypted
|
||||||
|
|
||||||
def set_title_version(self, title_version: str | int) -> None:
|
def set_title_version(self, title_version: str | int) -> None:
|
||||||
@ -170,6 +194,8 @@ class Title:
|
|||||||
bytes
|
bytes
|
||||||
The decrypted content listed in the content record.
|
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)
|
dec_content = self.content.get_content_by_index(index, self.ticket.get_title_key(), skip_hash)
|
||||||
return dec_content
|
return dec_content
|
||||||
|
|
||||||
@ -189,6 +215,8 @@ class Title:
|
|||||||
bytes
|
bytes
|
||||||
The decrypted content listed in the content record.
|
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)
|
dec_content = self.content.get_content_by_cid(cid, self.ticket.get_title_key(), skip_hash)
|
||||||
return dec_content
|
return dec_content
|
||||||
|
|
||||||
@ -296,13 +324,9 @@ class Title:
|
|||||||
def set_enc_content(self, enc_content: bytes, index: int, content_size: int, content_hash: bytes, cid: int = None,
|
def set_enc_content(self, enc_content: bytes, index: int, content_size: int, content_hash: bytes, cid: int = None,
|
||||||
content_type: int = None) -> None:
|
content_type: int = None) -> None:
|
||||||
"""
|
"""
|
||||||
Sets the content at the provided content index to the provided new encrypted content. The provided hash and
|
Sets the content at the provided index to the provided new encrypted content. The provided hash and content size
|
||||||
content size are set in the corresponding content record. A new Content ID or content type can also be
|
are set in the corresponding content record. A new Content ID or content type can also be specified, but if it
|
||||||
specified, but if it isn't then the current values are preserved.
|
isn't then the current values are preserved.
|
||||||
|
|
||||||
This uses the content index, which is the value tied to each content and used as the IV for encryption, rather
|
|
||||||
than the literal index in the array of content, because sometimes the contents end up out of order in a WAD
|
|
||||||
while still retaining the original indices.
|
|
||||||
|
|
||||||
This also updates the content records in the TMD after the content is set.
|
This also updates the content records in the TMD after the content is set.
|
||||||
|
|
||||||
@ -328,9 +352,9 @@ class Title:
|
|||||||
|
|
||||||
def set_content(self, dec_content: bytes, index: int, cid: int = None, content_type: int = None) -> None:
|
def set_content(self, dec_content: bytes, index: int, 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
|
Sets the content at the provided index to the provided new decrypted content. The hash and content size of this
|
||||||
of this content will be generated and then set in the corresponding content record. A new Content ID or content
|
content will be generated and then set in the corresponding content record. A new Content ID or content type can
|
||||||
type can also be specified, but if it isn't then the current values are preserved.
|
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.
|
This also updates the content records in the TMD after the content is set.
|
||||||
|
|
||||||
@ -356,16 +380,12 @@ class Title:
|
|||||||
sure that it matches the corresponding record. This content will then be encrypted using the title's Title Key
|
sure that it matches the corresponding record. This content will then be encrypted using the title's Title Key
|
||||||
before being loaded.
|
before being loaded.
|
||||||
|
|
||||||
This uses the content index, which is the value tied to each content and used as the IV for encryption, rather
|
|
||||||
than the literal index in the array of content, because sometimes the contents end up out of order in a WAD
|
|
||||||
while still retaining the original indices.
|
|
||||||
|
|
||||||
Parameters
|
Parameters
|
||||||
----------
|
----------
|
||||||
dec_content : bytes
|
dec_content : bytes
|
||||||
The decrypted content to load.
|
The decrypted content to load.
|
||||||
index : int
|
index : int
|
||||||
The content index to load the content at.
|
The index to load the content at.
|
||||||
"""
|
"""
|
||||||
# Load the decrypted content.
|
# Load the decrypted content.
|
||||||
self.content.load_content(dec_content, index, self.ticket.get_title_key())
|
self.content.load_content(dec_content, index, self.ticket.get_title_key())
|
||||||
@ -382,6 +402,7 @@ class Title:
|
|||||||
after any changes to the TMD or Ticket, and before dumping the Title object into a WAD to ensure that the WAD
|
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.
|
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.tmd.fakesign()
|
||||||
self.ticket.fakesign()
|
self.ticket.fakesign()
|
||||||
|
|
||||||
@ -403,3 +424,34 @@ class Title:
|
|||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
return False
|
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
|
||||||
|
@ -6,6 +6,7 @@
|
|||||||
import io
|
import io
|
||||||
import binascii
|
import binascii
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import math
|
||||||
import struct
|
import struct
|
||||||
from typing import List
|
from typing import List
|
||||||
from enum import IntEnum as _IntEnum
|
from enum import IntEnum as _IntEnum
|
||||||
@ -82,7 +83,7 @@ class TMD:
|
|||||||
self.signature = tmd_data.read(256)
|
self.signature = tmd_data.read(256)
|
||||||
# Signing certificate issuer.
|
# Signing certificate issuer.
|
||||||
tmd_data.seek(0x140)
|
tmd_data.seek(0x140)
|
||||||
self.signature_issuer = str(tmd_data.read(64).decode())
|
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 version, seems to usually be 0, but I've seen references to other numbers.
|
||||||
tmd_data.seek(0x180)
|
tmd_data.seek(0x180)
|
||||||
self.tmd_version = int.from_bytes(tmd_data.read(1))
|
self.tmd_version = int.from_bytes(tmd_data.read(1))
|
||||||
@ -174,7 +175,10 @@ class TMD:
|
|||||||
# Padding to 64 bytes.
|
# Padding to 64 bytes.
|
||||||
tmd_data += b'\x00' * 60
|
tmd_data += b'\x00' * 60
|
||||||
# Signing certificate issuer.
|
# Signing certificate issuer.
|
||||||
tmd_data += str.encode(self.signature_issuer)
|
signature_issuer = self.signature_issuer.encode()
|
||||||
|
while len(signature_issuer) < 0x40:
|
||||||
|
signature_issuer += b'\x00'
|
||||||
|
tmd_data += signature_issuer
|
||||||
# TMD version.
|
# TMD version.
|
||||||
tmd_data += int.to_bytes(self.tmd_version, 1)
|
tmd_data += int.to_bytes(self.tmd_version, 1)
|
||||||
# Certificate Authority CRL version.
|
# Certificate Authority CRL version.
|
||||||
@ -390,14 +394,71 @@ class TMD:
|
|||||||
raise IndexError("Invalid content record! TMD lists '" + str(self.num_contents - 1) +
|
raise IndexError("Invalid content record! TMD lists '" + str(self.num_contents - 1) +
|
||||||
"' contents but index was '" + str(record) + "'!")
|
"' contents but index was '" + str(record) + "'!")
|
||||||
|
|
||||||
|
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):
|
class AccessFlags(_IntEnum):
|
||||||
AHB = 0
|
AHB = 0
|
||||||
DVD_VIDEO = 1
|
DVD_VIDEO = 1
|
||||||
|
|
||||||
def get_access_right(self, flag: int) -> bool:
|
def get_access_right(self, flag: int) -> bool:
|
||||||
"""
|
"""
|
||||||
Gets whether an access rights flag is enabled or not. This is done by checking the specified bit. Possible flags
|
Gets whether the specified access rights flag is enabled or not. This is done by checking the specified bit.
|
||||||
and their corresponding bits are defined in the AccessFlags enum.
|
Possible flags and their corresponding bits are defined in AccessFlags.
|
||||||
|
|
||||||
Parameters
|
Parameters
|
||||||
----------
|
----------
|
||||||
@ -408,6 +469,10 @@ class TMD:
|
|||||||
-------
|
-------
|
||||||
bool
|
bool
|
||||||
True if the flag is enabled, False otherwise.
|
True if the flag is enabled, False otherwise.
|
||||||
|
|
||||||
|
See Also
|
||||||
|
--------
|
||||||
|
libWiiPy.title.tmd.TMD.AccessFlags
|
||||||
"""
|
"""
|
||||||
return bool(self.access_rights & _bitmask(flag))
|
return bool(self.access_rights & _bitmask(flag))
|
||||||
|
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
# "types.py" from libWiiPy by NinjaCheetah & Contributors
|
# "types.py" from libWiiPy by NinjaCheetah & Contributors
|
||||||
# https://github.com/NinjaCheetah/libWiiPy
|
# https://github.com/NinjaCheetah/libWiiPy
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user