├── src ├── __init__.py └── libWiiPy │ ├── py.typed │ ├── media │ ├── __init__.py │ └── banner.py │ ├── archive │ ├── __init__.py │ ├── ash.py │ └── lz77.py │ ├── nand │ ├── __init__.py │ ├── sys.py │ ├── setting.py │ └── emunand.py │ ├── __init__.py │ ├── title │ ├── __init__.py │ ├── commonkeys.py │ ├── types.py │ ├── versions.py │ ├── crypto.py │ ├── iospatcher.py │ ├── wad.py │ ├── cert.py │ ├── ticket.py │ ├── nus.py │ └── title.py │ ├── shared.py │ └── constants.py ├── test ├── title │ ├── __init__.py │ ├── commonkeys_test.py │ └── nus_test.py └── __init__.py ├── docs ├── source │ ├── banner.png │ ├── banner_old.png │ ├── titles │ │ ├── title-module.md │ │ ├── nus-downloading.md │ │ ├── title-anatomy.md │ │ └── extracting-titles.md │ ├── title │ │ ├── types.md │ │ ├── commonkeys.md │ │ ├── versions.md │ │ ├── content.md │ │ ├── tmd.md │ │ ├── iospatcher.md │ │ ├── ticket.md │ │ ├── crypto.md │ │ ├── nus.md │ │ ├── wad.md │ │ ├── cert.md │ │ ├── title.title.md │ │ └── title.md │ ├── archive │ │ ├── lz77.md │ │ ├── ash.md │ │ ├── u8.md │ │ └── archive.md │ ├── media │ │ ├── banner.md │ │ └── media.md │ ├── nand │ │ ├── sys.md │ │ ├── emunand.md │ │ ├── setting.md │ │ └── nand.md │ ├── index.md │ ├── api.md │ ├── usage │ │ ├── installation.md │ │ └── getting-started.md │ └── conf.py ├── Makefile └── make.bat ├── requirements.txt ├── LICENSE ├── pyproject.toml ├── .github └── workflows │ └── sphinx-docs.yml ├── .gitignore └── README.md /src/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/libWiiPy/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/title/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/source/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NinjaCheetah/libWiiPy/HEAD/docs/source/banner.png -------------------------------------------------------------------------------- /docs/source/banner_old.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NinjaCheetah/libWiiPy/HEAD/docs/source/banner_old.png -------------------------------------------------------------------------------- /src/libWiiPy/media/__init__.py: -------------------------------------------------------------------------------- 1 | # "media/__init__.py" from libWiiPy by NinjaCheetah & Contributors 2 | # https://github.com/NinjaCheetah/libWiiPy 3 | 4 | from .banner import * 5 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | build 2 | pycryptodome 3 | requests 4 | types-requests 5 | sphinx 6 | sphinx-book-theme 7 | myst-parser 8 | sphinx-copybutton 9 | sphinx-tippy 10 | sphinx-design 11 | -------------------------------------------------------------------------------- /docs/source/titles/title-module.md: -------------------------------------------------------------------------------- 1 | # The Title Module 2 | 3 | 4 | 5 | Pardon our dust! This website is still under construction, and we haven't quite gotten to this one yet. 6 | -------------------------------------------------------------------------------- /docs/source/titles/nus-downloading.md: -------------------------------------------------------------------------------- 1 | # Downloading from the NUS 2 | 3 | 4 | 5 | Pardon our dust! This website is still under construction, and we haven't quite gotten to this one yet. 6 | -------------------------------------------------------------------------------- /src/libWiiPy/archive/__init__.py: -------------------------------------------------------------------------------- 1 | # "archive/__init__.py" from libWiiPy by NinjaCheetah & Contributors 2 | # https://github.com/NinjaCheetah/libWiiPy 3 | 4 | from .ash import * 5 | from .lz77 import * 6 | from .u8 import * 7 | -------------------------------------------------------------------------------- /src/libWiiPy/nand/__init__.py: -------------------------------------------------------------------------------- 1 | # "nand/__init__.py" from libWiiPy by NinjaCheetah & Contributors 2 | # https://github.com/NinjaCheetah/libWiiPy 3 | 4 | from .emunand import * 5 | from .setting import * 6 | from .sys import * 7 | -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- 1 | # "__init__.py" from libWiiPy by NinjaCheetah & Contributors 2 | # https://github.com/NinjaCheetah/libWiiPy 3 | # 4 | # Complete set of tests to be run. 5 | 6 | import unittest 7 | 8 | from .title.commonkeys_test import * 9 | from .title.nus_test import * 10 | 11 | if __name__ == '__main__': 12 | unittest.main() 13 | -------------------------------------------------------------------------------- /docs/source/title/types.md: -------------------------------------------------------------------------------- 1 | # libWiiPy.title.types Module 2 | 3 | ## Description 4 | 5 | The `libWiiPy.title.types` module provides shared types used across the title module. 6 | 7 | ## Module Contents 8 | 9 | ```{eval-rst} 10 | .. automodule:: libWiiPy.title.types 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | ``` 15 | -------------------------------------------------------------------------------- /docs/source/title/commonkeys.md: -------------------------------------------------------------------------------- 1 | # libWiiPy.title.commonkeys Module 2 | 3 | ## Description 4 | 5 | The `libWiiPy.title.commonkeys` module simply provides easy access to the Wii's common encryption keys. 6 | 7 | ## Module Contents 8 | 9 | ```{eval-rst} 10 | .. automodule:: libWiiPy.title.commonkeys 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | ``` 15 | -------------------------------------------------------------------------------- /docs/source/title/versions.md: -------------------------------------------------------------------------------- 1 | # libWiiPy.title.versions Module 2 | 3 | ## Description 4 | 5 | The `libWiiPy.title.versions` module provides functions for converting the format that a title's version is in. 6 | 7 | ## Module Contents 8 | 9 | ```{eval-rst} 10 | .. automodule:: libWiiPy.title.versions 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | ``` 15 | -------------------------------------------------------------------------------- /docs/source/title/content.md: -------------------------------------------------------------------------------- 1 | # libWiiPy.title.content Module 2 | 3 | ## Description 4 | 5 | The `libWiiPy.title.content` module provides support for parsing, adding, removing, and editing content files from a digital Wii title. 6 | 7 | ## Module Contents 8 | 9 | ```{eval-rst} 10 | .. automodule:: libWiiPy.title.content 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | ``` 15 | -------------------------------------------------------------------------------- /src/libWiiPy/__init__.py: -------------------------------------------------------------------------------- 1 | # "__init__.py" from libWiiPy by NinjaCheetah & Contributors 2 | # https://github.com/NinjaCheetah/libWiiPy 3 | # 4 | # These are the essential submodules from libWiiPy that you'd probably want imported by default. 5 | 6 | __all__ = ["archive", "media", "nand", "title"] 7 | 8 | from . import archive 9 | from . import media 10 | from . import nand 11 | from . import title 12 | -------------------------------------------------------------------------------- /docs/source/archive/lz77.md: -------------------------------------------------------------------------------- 1 | # libWiiPy.archive.lz77 Module 2 | 3 | ## Description 4 | 5 | The `libWiiPy.archive.lz77` module provides support for handling LZ77 compression, which is a compression format used across the Wii and other Nintendo consoles. 6 | 7 | ## Module Contents 8 | 9 | ```{eval-rst} 10 | .. automodule:: libWiiPy.archive.lz77 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | ``` 15 | -------------------------------------------------------------------------------- /src/libWiiPy/title/__init__.py: -------------------------------------------------------------------------------- 1 | # "title/__init__.py" from libWiiPy by NinjaCheetah & Contributors 2 | # https://github.com/NinjaCheetah/libWiiPy 3 | 4 | from .cert import * 5 | from .content import * 6 | from .crypto import * 7 | from .iospatcher import * 8 | from .nus import * 9 | from .ticket import * 10 | from .title import * 11 | from .tmd import * 12 | from .types import * 13 | from .versions import * 14 | from .wad import * 15 | -------------------------------------------------------------------------------- /docs/source/title/tmd.md: -------------------------------------------------------------------------------- 1 | # libWiiPy.title.tmd Module 2 | 3 | ## Description 4 | 5 | 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. 6 | 7 | ## Module Contents 8 | 9 | ```{eval-rst} 10 | .. automodule:: libWiiPy.title.tmd 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | ``` 15 | -------------------------------------------------------------------------------- /docs/source/title/iospatcher.md: -------------------------------------------------------------------------------- 1 | # libWiiPy.title.iospatcher Module 2 | 3 | ## Description 4 | 5 | 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. 6 | 7 | ## Module Contents 8 | 9 | ```{eval-rst} 10 | .. automodule:: libWiiPy.title.iospatcher 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | ``` 15 | -------------------------------------------------------------------------------- /docs/source/title/ticket.md: -------------------------------------------------------------------------------- 1 | # libWiiPy.title.ticket Module 2 | 3 | ## Description 4 | 5 | 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. 6 | 7 | ## Module Contents 8 | 9 | ```{eval-rst} 10 | .. automodule:: libWiiPy.title.ticket 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | ``` 15 | -------------------------------------------------------------------------------- /docs/source/media/banner.md: -------------------------------------------------------------------------------- 1 | # libWiiPy.media.banner Module 2 | 3 | ## Description 4 | 5 | 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. 6 | 7 | ## Module Contents 8 | 9 | ```{eval-rst} 10 | .. automodule:: libWiiPy.media.banner 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | ``` 15 | -------------------------------------------------------------------------------- /docs/source/nand/sys.md: -------------------------------------------------------------------------------- 1 | # libWiiPy.nand.sys Module 2 | 3 | ## Description 4 | 5 | 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. 6 | 7 | ## Module Contents 8 | 9 | ```{eval-rst} 10 | .. automodule:: libWiiPy.nand.sys 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | ``` 15 | -------------------------------------------------------------------------------- /docs/source/title/crypto.md: -------------------------------------------------------------------------------- 1 | # libWiiPy.title.crypto Module 2 | 3 | ## Description 4 | 5 | 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. 6 | 7 | ## Module Contents 8 | 9 | ```{eval-rst} 10 | .. automodule:: libWiiPy.title.crypto 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | ``` 15 | -------------------------------------------------------------------------------- /docs/source/title/nus.md: -------------------------------------------------------------------------------- 1 | # libWiiPy.title.nus Module 2 | 3 | ## Description 4 | 5 | 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. 6 | 7 | ## Module Contents 8 | 9 | ```{eval-rst} 10 | .. automodule:: libWiiPy.title.nus 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | :special-members: __call__ 15 | ``` 16 | -------------------------------------------------------------------------------- /docs/source/nand/emunand.md: -------------------------------------------------------------------------------- 1 | # libWiiPy.nand.emunand Module 2 | 3 | ## Description 4 | 5 | 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. 6 | 7 | ## Module Contents 8 | 9 | ```{eval-rst} 10 | .. automodule:: libWiiPy.nand.emunand 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | ``` 15 | -------------------------------------------------------------------------------- /docs/source/title/wad.md: -------------------------------------------------------------------------------- 1 | # libWiiPy.title.wad Module 2 | 3 | ## Description 4 | 5 | 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. 6 | 7 | ## Module Contents 8 | 9 | ```{eval-rst} 10 | .. automodule:: libWiiPy.title.wad 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | ``` 15 | -------------------------------------------------------------------------------- /docs/source/archive/ash.md: -------------------------------------------------------------------------------- 1 | # libWiiPy.archive.ash Module 2 | 3 | ## Description 4 | 5 | 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. 6 | 7 | At present, libWiiPy only has support for decompressing ASH files, with compression as a planned feature for the future. 8 | 9 | ## Module Contents 10 | 11 | ```{eval-rst} 12 | .. automodule:: libWiiPy.archive.ash 13 | :members: 14 | :undoc-members: 15 | :show-inheritance: 16 | ``` 17 | -------------------------------------------------------------------------------- /docs/source/title/cert.md: -------------------------------------------------------------------------------- 1 | # libWiiPy.title.cert Module 2 | 3 | ## Description 4 | 5 | The `libWiiPy.title.cert` module provides support for parsing the various signing certificates used by the Wii for content validation. 6 | 7 | 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. 8 | 9 | ## Module Contents 10 | 11 | ```{eval-rst} 12 | .. automodule:: libWiiPy.title.cert 13 | :members: 14 | :undoc-members: 15 | :show-inheritance: 16 | ``` 17 | -------------------------------------------------------------------------------- /docs/source/nand/setting.md: -------------------------------------------------------------------------------- 1 | # libWiiPy.nand.setting Module 2 | 3 | ## Description 4 | 5 | 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. 6 | 7 | This module allows you to encrypt or decrypt this file, and exposes the keys stored in it for editing. 8 | 9 | ## Module Contents 10 | 11 | ```{eval-rst} 12 | .. automodule:: libWiiPy.nand.setting 13 | :members: 14 | :undoc-members: 15 | :show-inheritance: 16 | ``` 17 | -------------------------------------------------------------------------------- /docs/source/media/media.md: -------------------------------------------------------------------------------- 1 | # libWiiPy.media Package 2 | 3 | ## Description 4 | 5 | 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. 6 | 7 | ## Modules 8 | 9 | | Module | Description | 10 | |----------------------------------------|---------------------------------------------------| 11 | | [libWiiPy.media.banner](/media/banner) | Provides support for basic channel banner parsing | 12 | 13 | ## Full Package Contents 14 | 15 | ```{toctree} 16 | :maxdepth: 4 17 | 18 | /media/banner 19 | ``` 20 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/source/archive/u8.md: -------------------------------------------------------------------------------- 1 | # libWiiPy.archive.u8 Module 2 | 3 | ## Description 4 | 5 | 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. 6 | 7 | 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. 8 | 9 | ## Module Contents 10 | 11 | ```{eval-rst} 12 | .. automodule:: libWiiPy.archive.u8 13 | :members: 14 | :undoc-members: 15 | :show-inheritance: 16 | ``` 17 | -------------------------------------------------------------------------------- /test/title/commonkeys_test.py: -------------------------------------------------------------------------------- 1 | # "commonkeys_test.py" from libWiiPy by NinjaCheetah & Contributors 2 | # https://github.com/NinjaCheetah/libWiiPy 3 | 4 | import unittest 5 | 6 | from libWiiPy import title 7 | 8 | 9 | class TestCommonKeys(unittest.TestCase): 10 | def test_common(self): 11 | self.assertEqual(title.get_common_key(0), b'\xeb\xe4*"^\x85\x93\xe4H\xd9\xc5Es\x81\xaa\xf7') 12 | 13 | def test_korean(self): 14 | self.assertEqual(title.get_common_key(1), b'c\xb8+\xb4\xf4aN.\x13\xf2\xfe\xfb\xbaL\x9b~') 15 | 16 | def test_vwii(self): 17 | self.assertEqual(title.get_common_key(2), b'0\xbf\xc7n|\x19\xaf\xbb#\x1630\xce\xd7\xc2\x8d') 18 | 19 | 20 | if __name__ == '__main__': 21 | unittest.main() 22 | -------------------------------------------------------------------------------- /docs/source/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | sd_hide_title: true 3 | --- 4 | 5 | # Overview 6 | 7 | # libWiiPy Documentation 8 | 9 | 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. 10 | 11 | Just need to see the API? [libWiiPy API Documentation](/api) 12 | 13 | ```{toctree} 14 | :hidden: 15 | 16 | self 17 | ``` 18 | 19 | ```{toctree} 20 | :hidden: 21 | :caption: The Basics 22 | 23 | usage/installation.md 24 | usage/getting-started.md 25 | ``` 26 | 27 | ```{toctree} 28 | :hidden: 29 | :caption: Working with Titles 30 | 31 | titles/title-anatomy.md 32 | titles/extracting-titles.md 33 | titles/title-module.md 34 | titles/nus-downloading.md 35 | ``` 36 | 37 | ```{toctree} 38 | :hidden: 39 | :caption: More 40 | 41 | api.md 42 | ``` 43 | 44 | ## Indices and tables 45 | 46 | * [Full Index]() 47 | * 48 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | %SPHINXBUILD% >NUL 2>NUL 14 | if errorlevel 9009 ( 15 | echo. 16 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 17 | echo.installed, then set the SPHINXBUILD environment variable to point 18 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 19 | echo.may add the Sphinx directory to PATH. 20 | echo. 21 | echo.If you don't have Sphinx installed, grab it from 22 | echo.https://www.sphinx-doc.org/ 23 | exit /b 1 24 | ) 25 | 26 | if "%1" == "" goto help 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/source/archive/archive.md: -------------------------------------------------------------------------------- 1 | # libWiiPy.archive Package 2 | 3 | ## Description 4 | 5 | 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. 6 | 7 | ## Modules 8 | 9 | | Module | Description | 10 | |----------------------------------------|---------------------------------------------------------| 11 | | [libWiiPy.archive.ash](/archive/ash) | Provides support for decompressing ASH archives | 12 | | [libWiiPy.archive.lz77](/archive/lz77) | Provides support for the LZ77 compression scheme | 13 | | [libWiiPy.archive.u8](/archive/u8) | Provides support for packing and extracting U8 archives | 14 | 15 | ## Full Package Contents 16 | 17 | ```{toctree} 18 | :maxdepth: 4 19 | 20 | /archive/ash 21 | /archive/lz77 22 | /archive/u8 23 | ``` 24 | -------------------------------------------------------------------------------- /src/libWiiPy/media/banner.py: -------------------------------------------------------------------------------- 1 | # "title/banner.py" from libWiiPy by NinjaCheetah & Contributors 2 | # https://github.com/NinjaCheetah/libWiiPy 3 | # 4 | # See https://wiibrew.org/wiki/Opening.bnr for details about the Wii's banner format 5 | 6 | from dataclasses import dataclass as _dataclass 7 | 8 | 9 | @_dataclass 10 | class IMD5Header: 11 | """ 12 | An IMD5Header object that contains the properties of an IMD5 header. These headers precede the data of banner.bin 13 | and icon.bin inside the banner (00000000.app) of a channel, and are used to verify the data of those files. 14 | 15 | An IMD5 header is always 32 bytes long. 16 | 17 | :ivar magic: Magic number for the header, should be "IMD5". 18 | :ivar file_size: The size of the file this header precedes. 19 | :ivar zeros: 8 bytes of zero padding. 20 | :ivar md5_hash: The MD5 hash of the file this header precedes. 21 | """ 22 | magic: str # Should always be "IMD5" 23 | file_size: int 24 | zeros: int 25 | md5_hash: bytes 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2024 NinjaCheetah 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /docs/source/api.md: -------------------------------------------------------------------------------- 1 | # API Documentation 2 | 3 | libWiiPy is divided up into a few subpackages to organize related features. 4 | 5 | | Package | Description | 6 | |--------------------------------------|-----------------------------------------------------------------| 7 | | [libWiiPy.archive](/archive/archive) | Used to pack and extract archive formats used on the Wii | 8 | | [libWiiPy.media](/media/media) | Used for parsing and manipulating media formats used on the Wii | 9 | | [libWiiPy.nand](/nand/nand) | Used for working with EmuNANDs and core system files on the Wii | 10 | | [libWiiPy.title](/title/title) | Used for parsing and manipulating Wii titles | 11 | 12 | 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..`. 13 | 14 | ## Full Package Contents 15 | 16 | ```{toctree} 17 | :maxdepth: 8 18 | 19 | /archive/archive 20 | /media/media 21 | /nand/nand 22 | /title/title 23 | ``` 24 | -------------------------------------------------------------------------------- /docs/source/nand/nand.md: -------------------------------------------------------------------------------- 1 | # libWiiPy.nand Package 2 | 3 | ## Description 4 | 5 | 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. 6 | 7 | ## Modules 8 | 9 | | Module | Description | 10 | |----------------------------------------|----------------------------------------------------------------------------------------------------------------------------------| 11 | | [libWiiPy.nand.emunand](/nand/emunand) | Provides support for parsing, creating, and editing EmuNANDs | 12 | | [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 | 13 | | [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 | 14 | 15 | ## Full Package Contents 16 | 17 | ```{toctree} 18 | :maxdepth: 4 19 | 20 | /nand/emunand 21 | /nand/setting 22 | /nand/sys 23 | ``` 24 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "libWiiPy" 3 | version = "1.0.0" 4 | authors = [ 5 | { name="NinjaCheetah", email="ninjacheetah@ncxprogramming.com" }, 6 | { name="Lillian Skinner", email="lillian@randommeaninglesscharacters.com" } 7 | ] 8 | description = "A modern Python library for handling files used by the Wii" 9 | readme = "README.md" 10 | requires-python = ">=3.10" 11 | classifiers = [ 12 | # How mature is this project? Common values are 13 | # 3 - Alpha 14 | # 4 - Beta 15 | # 5 - Production/Stable 16 | "Development Status :: 5 - Production/Stable", 17 | "Intended Audience :: Developers", 18 | "License :: OSI Approved :: MIT License", 19 | "Operating System :: OS Independent", 20 | "Programming Language :: Python :: 3.10", 21 | "Programming Language :: Python :: 3.11", 22 | "Programming Language :: Python :: 3.12", 23 | ] 24 | dependencies = [ 25 | "pycryptodome", 26 | "requests", 27 | "types-requests" 28 | ] 29 | keywords = ["Wii", "wii"] 30 | 31 | [project.urls] 32 | Homepage = "https://github.com/NinjaCheetah/libWiiPy" 33 | Documentation = "https://ninjacheetah.github.io/libWiiPy/" 34 | Repository = "https://github.com/NinjaCheetah/libWiiPy.git" 35 | Issues = "https://github.com/NinjaCheetah/libWiiPy/issues" 36 | 37 | [build-system] 38 | requires = ["setuptools>=61.0"] 39 | build-backend = "setuptools.build_meta" 40 | -------------------------------------------------------------------------------- /src/libWiiPy/shared.py: -------------------------------------------------------------------------------- 1 | # "shared.py" from libWiiPy by NinjaCheetah & Contributors 2 | # https://github.com/NinjaCheetah/libWiiPy 3 | # 4 | # This file defines general functions that may be useful in other modules of libWiiPy. Putting them here cuts down on 5 | # clutter in other files. 6 | 7 | 8 | def _align_value(value, alignment=64) -> int: 9 | """ 10 | Aligns the provided value to the set alignment (defaults to 64). Private function used by other libWiiPy modules. 11 | 12 | Parameters 13 | ---------- 14 | value : int 15 | The value to align. 16 | alignment : int 17 | The number to align to. Defaults to 64. 18 | 19 | Returns 20 | ------- 21 | int 22 | The aligned value. 23 | """ 24 | if (value % alignment) != 0: 25 | aligned_value = value + (alignment - (value % alignment)) 26 | return aligned_value 27 | return value 28 | 29 | 30 | def _pad_bytes(data, alignment=64) -> bytes: 31 | """ 32 | Pads the provided bytes object to the provided alignment (defaults to 64). Private function used by other libWiiPy 33 | modules. 34 | 35 | Parameters 36 | ---------- 37 | data : bytes 38 | The data to align. 39 | alignment : int 40 | The number to align to. Defaults to 64. 41 | 42 | Returns 43 | ------- 44 | bytes 45 | The aligned data. 46 | """ 47 | while (len(data) % alignment) != 0: 48 | data += b'\x00' 49 | return data 50 | -------------------------------------------------------------------------------- /src/libWiiPy/constants.py: -------------------------------------------------------------------------------- 1 | # "constants.py" from libWiiPy by NinjaCheetah & Contributors 2 | # https://github.com/NinjaCheetah/libWiiPy 3 | # 4 | # This file defines constant values referenced across the library. 5 | 6 | _WII_MENU_VERSIONS = { 7 | "Prelaunch": [0, 1, 2], 8 | "1.0J": 64, 9 | "1.0U": 33, 10 | "1.0E": 34, 11 | "2.0J": 128, 12 | "2.0U": 97, 13 | "2.0E": 130, 14 | "2.1E": 162, 15 | "2.2J": 192, 16 | "2.2U": 193, 17 | "2.2E": 194, 18 | "3.0J": 224, 19 | "3.0U": 225, 20 | "3.0E": 226, 21 | "3.1J": 256, 22 | "3.1U": 257, 23 | "3.1E": 258, 24 | "3.2J": 288, 25 | "3.2U": 289, 26 | "3.2E": 290, 27 | "3.3J": 352, 28 | "3.3U": 353, 29 | "3.3E": 354, 30 | "3.3K": 326, 31 | "3.4J": 384, 32 | "3.4U": 385, 33 | "3.4E": 386, 34 | "3.5K": 390, 35 | "4.0J": 416, 36 | "4.0U": 417, 37 | "4.0E": 418, 38 | "4.1J": 448, 39 | "4.1U": 449, 40 | "4.1E": 450, 41 | "4.1K": 454, 42 | "4.2J": 480, 43 | "4.2U": 481, 44 | "4.2E": 482, 45 | "4.2K": 486, 46 | "4.3J": 512, 47 | "4.3U": 513, 48 | "4.3E": 514, 49 | "4.3K": 518, 50 | "4.3U-Mini": 4609, 51 | "4.3E-Mini": 4610 52 | } 53 | 54 | 55 | _VWII_MENU_VERSIONS = { 56 | "vWii-1.0.0J": 512, 57 | "vWii-1.0.0U": 513, 58 | "vWii-1.0.0E": 514, 59 | "vWii-4.0.0J": 544, 60 | "vWii-4.0.0U": 545, 61 | "vWii-4.0.0E": 546, 62 | "vWii-5.2.0J": 608, 63 | "vWii-5.2.0U": 609, 64 | "vWii-5.2.0E": 610, 65 | } 66 | -------------------------------------------------------------------------------- /docs/source/usage/installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | The first thing you'll want to do to get set up is to install the `libWiiPy` package. This can be done one of two ways. 3 | 4 | **For a more stable experience,** you can install the latest release from PyPI just like any other Python package: 5 | ```shell 6 | pip install libWiiPy 7 | ``` 8 | 9 | **If you prefer to live on the edge** (or just want to use features currently in development), you can also build the latest version from git: 10 | ```shell 11 | pip install git+https://github.com/NinjaCheetah/libWiiPy 12 | ``` 13 | 14 | If you'd like to check the latest release, our PyPI page can be found [here](https://pypi.org/project/libWiiPy/). Release notes and build files for each release can be found over on our [GitHub releases page](https://github.com/NinjaCheetah/libWiiPy/releases/latest). 15 | 16 | :::{caution} 17 | libWiiPy is under heavy active development! While we try our hardest to not make breaking changes, things move quickly and that sometimes can cause problems. 18 | ::: 19 | 20 | For those who are truly brave and want to experiment with the latest features, you can try building from an alternative branch. However, if you're going to do this, please be aware that features on branches other than `main` are likely very incomplete, and potentially completely broken. New features are only merged into `main` once they've been proven to at least work for their intended purpose. This does not guarantee a bug-free experience, but you are significantly less likely to run into show-stopping bugs. 21 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # For the full list of built-in configuration values, see the documentation: 4 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 5 | 6 | from datetime import date 7 | 8 | # -- Project information ----------------------------------------------------- 9 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information 10 | 11 | project = 'libWiiPy' 12 | copyright = f'{date.today().year}, NinjaCheetah & Contributors' 13 | author = 'NinjaCheetah & Contributors' 14 | version = 'main' 15 | release = 'main' 16 | 17 | # -- General configuration --------------------------------------------------- 18 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration 19 | 20 | extensions = [ 21 | 'myst_parser', 22 | 'sphinx.ext.napoleon', 23 | 'sphinx_copybutton', 24 | 'sphinx_tippy', 25 | 'sphinx_design' 26 | ] 27 | 28 | templates_path = ['_templates'] 29 | exclude_patterns = ["Thumbs.db", ".DS_Store"] 30 | 31 | # -- Options for HTML output ------------------------------------------------- 32 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output 33 | 34 | html_theme = 'sphinx_book_theme' 35 | html_static_path = ['_static'] 36 | html_logo = "banner.png" 37 | html_title = "libWiiPy API Docs" 38 | html_theme_options = { 39 | "repository_url": "https://github.com/NinjaCheetah/libWiiPy", 40 | "use_repository_button": True, 41 | "show_toc_level": 3 42 | } 43 | 44 | # MyST Configuration 45 | 46 | myst_enable_extensions = ['colon_fence', 'deflist'] 47 | -------------------------------------------------------------------------------- /src/libWiiPy/title/commonkeys.py: -------------------------------------------------------------------------------- 1 | # "title/commonkeys.py" from libWiiPy by NinjaCheetah & Contributors 2 | # https://github.com/NinjaCheetah/libWiiPy 3 | 4 | import binascii 5 | 6 | common_key = 'ebe42a225e8593e448d9c5457381aaf7' 7 | korean_key = '63b82bb4f4614e2e13f2fefbba4c9b7e' 8 | vwii_key = '30bfc76e7c19afbb23163330ced7c28d' 9 | 10 | development_key = 'a1604a6a7123b529ae8bec32c816fcaa' 11 | 12 | 13 | def get_common_key(common_key_index, dev=False) -> bytes: 14 | """ 15 | Gets the specified Wii Common Key based on the index provided. If an invalid common key index is provided, this 16 | function falls back on always returning key 0 (the Common Key). If the kwarg "dev" is specified, then key 0 will 17 | point to the development common key rather than the retail one. Keys 1 and 2 are unaffected by this argument. 18 | 19 | Possible values for common_key_index: 0: Common Key, 1: Korean Key, 2: vWii Key 20 | 21 | Parameters 22 | ---------- 23 | common_key_index : int 24 | The index of the common key to be returned. 25 | dev : bool 26 | If the dev keys should be used in place of the retail keys. Only affects key 0. 27 | 28 | Returns 29 | ------- 30 | bytes 31 | The specified common key, in binary format. 32 | """ 33 | match common_key_index: 34 | case 0: 35 | if dev: 36 | return binascii.unhexlify(development_key) 37 | else: 38 | return binascii.unhexlify(common_key) 39 | case 1: 40 | return binascii.unhexlify(korean_key) 41 | case 2: 42 | return binascii.unhexlify(vwii_key) 43 | case _: 44 | return binascii.unhexlify(common_key) 45 | -------------------------------------------------------------------------------- /docs/source/title/title.title.md: -------------------------------------------------------------------------------- 1 | # libWiiPy.title.title Module 2 | 3 | ## Description 4 | 5 | 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. 6 | 7 | An example of that idea can be seen with the method `get_content_by_index()`. In its original definition, which can be seen at , you are required to supply the Title Key for the title that the content is sourced from. In contrast, when using , 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 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. 8 | 9 | Because using allows many operations to be much simpler than if you manage the components separately, it's generally recommended to use it whenever possible. 10 | 11 | ## Module Contents 12 | 13 | ```{eval-rst} 14 | .. automodule:: libWiiPy.title.title 15 | :members: 16 | :undoc-members: 17 | ``` 18 | -------------------------------------------------------------------------------- /docs/source/usage/getting-started.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | Once you have libWiiPy installed, it's time to write your first code! 3 | 4 | As an example, let's say you have a TMD file with a generic name, `title.tmd`, and because of this you need to find out some information about it, so you know what title it belongs to. 5 | 6 | First off, let's import `libWiiPy`, and load up our file: 7 | ```pycon 8 | >>> import libWiiPy 9 | >>> tmd_file = open("title.tmd", "rb").read() 10 | >>> 11 | ``` 12 | 13 | Then we'll create a new TMD object, and load our file into it: 14 | ```pycon 15 | >>> tmd = libWiiPy.title.TMD() 16 | >>> tmd.load(tmd_file) 17 | >>> 18 | ``` 19 | 20 | And ta-da! We now have a new TMD object that can be used to find out whatever we need to know about this TMD. 21 | 22 | So, to find out what title this TMD is for, let's try looking at the TMD's `title_id` property, like this: 23 | ```pycon 24 | >>> print(tmd.title_id) 25 | 0000000100000002 26 | 27 | >>> 28 | ``` 29 | 30 | Aha! `0000000100000002`! That means this TMD belongs to the Wii Menu. But what version? Well, we can use the TMD's `title_version` property to check, like so: 31 | ```pycon 32 | >>> print(tmd.title_version) 33 | 513 34 | 35 | >>> 36 | ``` 37 | 38 | 513! So now we know that this TMD is from the Wii Menu, and is version 513, which is the version number used for v4.3U. 39 | 40 | So now you know how to identify what title and version a TMD file is from! But, realistically, trying to identify a lone unlabeled TMD file is not something you'll ever really need to do, either in your day-to-day life or in whatever program you're developing. In the next chapter, we'll dive in to working with more components of a title, which is a lot more useful for programs that need to manipulate them. 41 | 42 | The full documentation on the TMD class can be found here: 43 | -------------------------------------------------------------------------------- /src/libWiiPy/title/types.py: -------------------------------------------------------------------------------- 1 | # "title/types.py" from libWiiPy by NinjaCheetah & Contributors 2 | # https://github.com/NinjaCheetah/libWiiPy 3 | # 4 | # Shared types used across the title module. 5 | 6 | from dataclasses import dataclass as _dataclass 7 | from enum import IntEnum as _IntEnum, StrEnum as _StrEnum 8 | 9 | 10 | @_dataclass 11 | class ContentRecord: 12 | """ 13 | A content record object that contains the details of a content contained in a title. This information must match 14 | the content stored at the index in the record, or else the content will not decrypt properly, as the hash of the 15 | decrypted data will not match the hash in the content record. 16 | 17 | Attributes 18 | ---------- 19 | content_id : int 20 | The unique ID of the content. 21 | index : int 22 | The index of this content in the content records. 23 | content_type : int 24 | The type of the content. 25 | content_size : int 26 | The size of the content when decrypted. 27 | content_hash 28 | The SHA-1 hash of the decrypted content. 29 | """ 30 | content_id: int 31 | index: int 32 | content_type: int # Type of content, possible values of: 0x0001: Normal, 0x4001: DLC, 0x8001: Shared. 33 | content_size: int 34 | content_hash: bytes 35 | 36 | 37 | class ContentType(_IntEnum): 38 | """ 39 | The type of an individual piece of content. 40 | """ 41 | NORMAL = 0x0001 42 | DEVELOPMENT = 0x0002 43 | HASH_TREE = 0x0003 44 | DLC = 0x4001 45 | SHARED = 0x8001 46 | 47 | 48 | class TitleType(_StrEnum): 49 | """ 50 | The type of a title. 51 | """ 52 | SYSTEM = "00000001" 53 | GAME = "00010000" 54 | CHANNEL = "00010001" 55 | SYSTEM_CHANNEL = "00010002" 56 | GAME_CHANNEL = "00010004" 57 | DLC = "00010005" 58 | HIDDEN_CHANNEL = "00010008" 59 | 60 | 61 | class Region(_IntEnum): 62 | """ 63 | The region of a title. 64 | """ 65 | JPN = 0 66 | USA = 1 67 | EUR = 2 68 | WORLD = 3 69 | KOR = 4 70 | -------------------------------------------------------------------------------- /.github/workflows/sphinx-docs.yml: -------------------------------------------------------------------------------- 1 | # Workflow to build libWiiPy documentation with Sphinx and then publish it 2 | name: Build and publish documentation with Sphinx 3 | 4 | on: 5 | # Runs on pushes targeting the default branch 6 | push: 7 | branches: ["main"] 8 | 9 | # Allows you to run this workflow manually from the Actions tab 10 | workflow_dispatch: 11 | 12 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 13 | permissions: 14 | contents: read 15 | pages: write 16 | id-token: write 17 | 18 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 19 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 20 | concurrency: 21 | group: "pages" 22 | cancel-in-progress: false 23 | 24 | jobs: 25 | # Build job 26 | build: 27 | runs-on: ubuntu-latest 28 | steps: 29 | - name: Checkout 30 | uses: actions/checkout@v4 31 | - name: Setup Pages 32 | uses: actions/configure-pages@v5 33 | - name: Set up Python 3.11 34 | uses: actions/setup-python@v5 35 | with: 36 | python-version: "3.11" 37 | - name: Install Dependencies 38 | run: | 39 | python -m pip install --upgrade pip 40 | pip install -r requirements.txt 41 | - name: Build and Install libWiiPy for Sphinx 42 | run: | 43 | python -m build 44 | pip install dist/libwiipy*.tar.gz 45 | - name: Build Documentation with Sphinx 46 | run: | 47 | python -m sphinx -M html docs/source/ docs/build/ 48 | - name: Upload artifact 49 | uses: actions/upload-pages-artifact@v3 50 | with: 51 | # Upload only the build/html directory 52 | path: 'docs/build/html' 53 | 54 | # Deployment job 55 | deploy: 56 | environment: 57 | name: github-pages 58 | url: ${{ steps.deployment.outputs.page_url }} 59 | runs-on: ubuntu-latest 60 | needs: build 61 | steps: 62 | - name: Deploy to GitHub Pages 63 | id: deployment 64 | uses: actions/deploy-pages@v4 65 | -------------------------------------------------------------------------------- /docs/source/title/title.md: -------------------------------------------------------------------------------- 1 | # libWiiPy.title Package 2 | 3 | ## Description 4 | 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. 5 | 6 | ## Modules 7 | 8 | | Module | Description | 9 | |------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------| 10 | | [libWiiPy.title.cert](/title/cert) | Provides support for parsing and validating the certificates used for title verification | 11 | | [libWiiPy.title.commonkeys](/title/commonkeys) | Provides easy access to all common encryption keys | 12 | | [libWiiPy.title.content](/title/content) | Provides support for parsing and editing content included as part of digital titles | 13 | | [libWiiPy.title.crypto](/title/crypto) | Provides low-level cryptography functions used to handle encryption in other modules | 14 | | [libWiiPy.title.iospatcher](/title/iospatcher) | Provides an easy interface to apply patches to IOSes | 15 | | [libWiiPy.title.nus](/title/nus) | Provides support for downloading TMDs, Tickets, encrypted content, and the certificate chain from the Nintendo Update Servers | 16 | | [libWiiPy.title.ticket](/title/ticket) | Provides support for parsing and editing Tickets used for content decryption | 17 | | [libWiiPy.title.title](/title/title.title) | Provides high-level support for parsing and editing an entire title with the context of each component | 18 | | [libWiiPy.title.tmd](/title/tmd) | Provides support for parsing and editing TMDs (Title Metadata) | 19 | | [libWiiPy.title.wad](/title/wad) | Provides support for parsing and editing WAD files, allowing you to load each component into the other available classes | 20 | | [libWiiPy.title.types](/title/types) | Provides shared types used across the title module. | 21 | | [libWiiPy.title.versions](/title/versions) | Provides utility functions for converting the format that a title's version is in. | 22 | 23 | ## Full Package Contents 24 | 25 | ```{toctree} 26 | :maxdepth: 4 27 | 28 | /title/cert 29 | /title/commonkeys 30 | /title/content 31 | /title/crypto 32 | /title/iospatcher 33 | /title/nus 34 | /title/ticket 35 | /title/title.title 36 | /title/tmd 37 | /title/wad 38 | /title/types 39 | /title/versions 40 | ``` 41 | -------------------------------------------------------------------------------- /src/libWiiPy/title/versions.py: -------------------------------------------------------------------------------- 1 | # "title/versions.py" from libWiiPy by NinjaCheetah & Contributors 2 | # https://github.com/NinjaCheetah/libWiiPy 3 | # 4 | # Functions for converting the format that a title's version is in. 5 | 6 | from ..constants import _WII_MENU_VERSIONS, _VWII_MENU_VERSIONS 7 | 8 | 9 | def title_ver_dec_to_standard(version: int, title_id: str, vwii: bool = False) -> str: 10 | """ 11 | Converts a title's version from decimal form (vXXX, the way the version is stored in the TMD/Ticket) to its standard 12 | and human-readable form (vX.X). The Title ID is required as some titles handle this version differently from others. 13 | For the System Menu, the returned version will include the region code (ex. 4.3U). 14 | 15 | Parameters 16 | ---------- 17 | version : int 18 | The version of the title, in decimal form. 19 | title_id : str 20 | The Title ID that the version is associated with. 21 | vwii : bool 22 | Whether this title is for the vWii or not. Only relevant for the System Menu. 23 | 24 | Returns 25 | ------- 26 | str 27 | The version of the title, in standard form. 28 | """ 29 | if title_id == "0000000100000002": 30 | try: 31 | if vwii: 32 | return list(_VWII_MENU_VERSIONS.keys())[list(_VWII_MENU_VERSIONS.values()).index(version)] 33 | else: 34 | return list(_WII_MENU_VERSIONS.keys())[list(_WII_MENU_VERSIONS.values()).index(version)] 35 | except ValueError: 36 | raise ValueError(f"Unrecognized System Menu version \"{version}\".") 37 | else: 38 | # Typical titles use a two-byte version format where the upper byte is the major version, and the lower byte is 39 | # the minor version. 40 | return f"{version >> 8}.{version & 0xFF}" 41 | 42 | 43 | def title_ver_standard_to_dec(version: str, title_id: str) -> int: 44 | """ 45 | Converts a title's version from its standard and human-readable form (vX.X) to its decimal form (vXXX, the way the 46 | version is stored in the TMD/Ticket). The Title ID is required as some titles handle this version differently from 47 | others. For the System Menu, the supplied version must include the region code (ex. 4.3U) for the conversion to 48 | work correctly. 49 | 50 | Parameters 51 | ---------- 52 | version : str 53 | The version of the title, in standard form. 54 | title_id : str 55 | The Title ID that the version is associated with. 56 | 57 | Returns 58 | ------- 59 | int 60 | The version of the title, in decimal form. 61 | """ 62 | if title_id == "0000000100000002": 63 | for key in _WII_MENU_VERSIONS.keys(): 64 | if version.casefold() == key.casefold(): 65 | return _WII_MENU_VERSIONS[key] 66 | for key in _VWII_MENU_VERSIONS.keys(): 67 | if version.casefold() == key.casefold(): 68 | return _VWII_MENU_VERSIONS[key] 69 | raise ValueError(f"Unrecognized System Menu version \"{version}\".") 70 | else: 71 | version_str_split = version.split(".") 72 | version_out = (int(version_str_split[0]) << 8) + int(version_str_split[1]) 73 | return version_out 74 | -------------------------------------------------------------------------------- /test/title/nus_test.py: -------------------------------------------------------------------------------- 1 | # "nus_test.py" from libWiiPy by NinjaCheetah & Contributors 2 | # https://github.com/NinjaCheetah/libWiiPy 3 | 4 | import hashlib 5 | import unittest 6 | 7 | import libWiiPy 8 | 9 | 10 | class TestNUSDownloads(unittest.TestCase): 11 | def test_download_title(self): 12 | title = libWiiPy.title.download_title("0000000100000002", 513) 13 | title_hash = hashlib.sha1(title.dump_wad()).hexdigest() 14 | self.assertEqual(title_hash, "c5e25fdb1ae6921597058b9f07045be0b003c550") 15 | title = libWiiPy.title.download_title("0000000100000002", 513, wiiu_endpoint=True) 16 | title_hash = hashlib.sha1(title.dump_wad()).hexdigest() 17 | self.assertEqual(title_hash, "c5e25fdb1ae6921597058b9f07045be0b003c550") 18 | 19 | def test_download_tmd(self): 20 | tmd = libWiiPy.title.download_tmd("0000000100000002", 513) 21 | tmd_hash = hashlib.sha1(tmd).hexdigest() 22 | self.assertEqual(tmd_hash, "e8f9657d591b305e300c109b5641630aa4e2318b") 23 | tmd = libWiiPy.title.download_tmd("0000000100000002", 513, wiiu_endpoint=True) 24 | tmd_hash = hashlib.sha1(tmd).hexdigest() 25 | self.assertEqual(tmd_hash, "e8f9657d591b305e300c109b5641630aa4e2318b") 26 | with self.assertRaises(ValueError): 27 | libWiiPy.title.download_tmd("TEST_STRING") 28 | 29 | def test_download_ticket(self): 30 | ticket = libWiiPy.title.download_ticket("0000000100000002") 31 | ticket_hash = hashlib.sha1(ticket).hexdigest() 32 | self.assertEqual(ticket_hash, "7076891f96ad3e4a6148a4a308e4a12fc72cc4b5") 33 | ticket = libWiiPy.title.download_ticket("0000000100000002", wiiu_endpoint=True) 34 | ticket_hash = hashlib.sha1(ticket).hexdigest() 35 | self.assertEqual(ticket_hash, "7076891f96ad3e4a6148a4a308e4a12fc72cc4b5") 36 | with self.assertRaises(ValueError): 37 | libWiiPy.title.download_ticket("TEST_STRING") 38 | 39 | def test_download_cert(self): 40 | cert = libWiiPy.title.download_cert() 41 | self.assertIsNotNone(cert) 42 | cert = libWiiPy.title.download_cert(wiiu_endpoint=True) 43 | self.assertIsNotNone(cert) 44 | 45 | def test_download_content(self): 46 | content = libWiiPy.title.download_content("0000000100000002", 150) 47 | content_hash = hashlib.sha1(content).hexdigest() 48 | self.assertEqual(content_hash, "1f10abe6517d29950aa04c71b264c18d204ed363") 49 | content = libWiiPy.title.download_content("0000000100000002", 150, wiiu_endpoint=True) 50 | content_hash = hashlib.sha1(content).hexdigest() 51 | self.assertEqual(content_hash, "1f10abe6517d29950aa04c71b264c18d204ed363") 52 | with self.assertRaises(ValueError): 53 | libWiiPy.title.download_content("TEST_STRING", 150) 54 | with self.assertRaises(ValueError): 55 | libWiiPy.title.download_content("0000000100000002", -1) 56 | 57 | def test_download_contents(self): 58 | tmd = libWiiPy.title.TMD() 59 | tmd.load(libWiiPy.title.download_tmd("0000000100000002")) 60 | contents = libWiiPy.title.download_contents("0000000100000002", tmd) 61 | self.assertIsNotNone(contents) 62 | contents = libWiiPy.title.download_contents("0000000100000002", tmd, wiiu_endpoint=True) 63 | self.assertIsNotNone(contents) 64 | 65 | 66 | if __name__ == '__main__': 67 | unittest.main() 68 | -------------------------------------------------------------------------------- /docs/source/titles/title-anatomy.md: -------------------------------------------------------------------------------- 1 | # Anatomy of a Title 2 | 3 | Before we start working with titles, it's important to understand what components make up a title on the Wii, and how each of those components are handled in libWiiPy. If you're here, you likely already understand what a title is in the context of the Wii, but if not, [WiiBrew](https://wiibrew.org/wiki/Main_Page) is a great reference to learn more about the Wii's software. 4 | 5 | :::{note} 6 | "Title" can be used to refer to both digital titles preinstalled on the Wii and distributed via the Wii Shop Channel and system updates, as well as games released on discs. libWiiPy does not currently offer methods to interact with most data found on a game disc, so for all intents and purposes, "title" in this documentation is referring to digital titles only unless otherwise specified. 7 | ::: 8 | 9 | There are three major components of a title: the **TMD**, the **Ticket**, and the **contents**. A brief summary of each is provided below. 10 | 11 | ## TMD 12 | 13 | 14 | A **TMD** (**T**itle **M**eta**d**ata) contains basic information about a title, such as its Title ID, version, what IOS and version it's designed to run under, whether it's for the vWii or not, and more related information. The TMD also stores a list of content records that specify the index and ID of each content, as well as the SHA-1 hash of the decrypted content, to ensure that decryption was successful. 15 | 16 | In libWiiPy, a TMD is represented by a `TMD()` object, which is part of the `tmd` module in the `title` subpackge, and is imported automatically. A content record is represented by its own `ContentRecord()` object, which is a private class designed to only be used by other modules. 17 | 18 | ## Ticket 19 | 20 | 21 | A **Ticket** primarily contains the encrypted Title Key for a title, as well as the information required to decrypt that key. They come in two forms: common tickets, which are freely available from the Nintendo Update Servers (NUS), and personalized tickets, which are issued to your console specifically by the Wii Shop Channel (or at least they were before it closed, excluding the free titles still available). 22 | 23 | In libWiiPy, a Ticket is represented by a `Ticket()` object, which is part of the `ticket` module in the `title` subpackage, and is imported automatically. 24 | 25 | ## Content 26 | 27 | 28 | **Contents** are the files in a title that contain the actual data, whether that be the main executable or resources required by it. They're usually stored encrypted in a WAD file or on the NUS, until they are decrypted during installation to a console. The Title Key stored in the Ticket is required to decrypt the contents of a title. Each content has a matching record with its index and Content ID, as well as the SHA-1 hash of its decrypted data. These records are stored in the TMD. 29 | 30 | In libWiiPy, contents are represented by a `ContentRegion()` object, which is part of the `content` module in the `title` subpackge, and is imported automatically. A content record is represented by its own `ContentRecord()` object, which is a private class designed to only be used by other modules. 31 | 32 | To effectively work with a whole title, you'll need to understand the basics of these three components and the libWiiPy classes that are used to represent them. 33 | 34 | Now, let's get into how you'd use them to extract a title from a WAD file. 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | main.build/ 26 | main.dist/ 27 | .installed.cfg 28 | *.egg 29 | MANIFEST 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .nox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *.cover 51 | *.py,cover 52 | .hypothesis/ 53 | .pytest_cache/ 54 | cover/ 55 | 56 | # Translations 57 | *.mo 58 | *.pot 59 | 60 | # Django stuff: 61 | *.log 62 | local_settings.py 63 | db.sqlite3 64 | db.sqlite3-journal 65 | 66 | # Flask stuff: 67 | instance/ 68 | .webassets-cache 69 | 70 | # Scrapy stuff: 71 | .scrapy 72 | 73 | # Sphinx documentation 74 | docs/_build/ 75 | 76 | # PyBuilder 77 | .pybuilder/ 78 | target/ 79 | 80 | # Jupyter Notebook 81 | .ipynb_checkpoints 82 | 83 | # IPython 84 | profile_default/ 85 | ipython_config.py 86 | 87 | # pyenv 88 | # For a library or package, you might want to ignore these files since the code is 89 | # intended to run in multiple environments; otherwise, check them in: 90 | # .python-version 91 | 92 | # pipenv 93 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 94 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 95 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 96 | # install all needed dependencies. 97 | #Pipfile.lock 98 | 99 | # poetry 100 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 101 | # This is especially recommended for binary packages to ensure reproducibility, and is more 102 | # commonly ignored for libraries. 103 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 104 | #poetry.lock 105 | 106 | # pdm 107 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 108 | #pdm.lock 109 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 110 | # in version control. 111 | # https://pdm.fming.dev/#use-with-ide 112 | .pdm.toml 113 | 114 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 115 | __pypackages__/ 116 | 117 | # Celery stuff 118 | celerybeat-schedule 119 | celerybeat.pid 120 | 121 | # SageMath parsed files 122 | *.sage.py 123 | 124 | # Environments 125 | .env 126 | .venv 127 | env/ 128 | venv/ 129 | ENV/ 130 | env.bak/ 131 | venv.bak/ 132 | 133 | # Spyder project settings 134 | .spyderproject 135 | .spyproject 136 | 137 | # Rope project settings 138 | .ropeproject 139 | 140 | # mkdocs documentation 141 | /site 142 | 143 | # mypy 144 | .mypy_cache/ 145 | .dmypy.json 146 | dmypy.json 147 | 148 | # Pyre type checker 149 | .pyre/ 150 | 151 | # pytype static type analyzer 152 | .pytype/ 153 | 154 | # Cython debug symbols 155 | cython_debug/ 156 | 157 | # PyCharm 158 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 159 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 160 | # and can be added to the global gitignore or merged into this file. For a more nuclear 161 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 162 | .idea/ 163 | 164 | # Relevant files that are used for testing libWiiPy's features. 165 | *.tmd 166 | *.wad 167 | *.arc 168 | *.ash 169 | out_prod/ 170 | remakewad.pl 171 | 172 | # Also awful macOS files 173 | *._* 174 | *.DS_Store 175 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![banner](https://github.com/user-attachments/assets/eb30a500-6d27-42f1-bded-24221930a8e3) 2 | # libWiiPy 3 | 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. 4 | 5 | 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). 6 | 7 | 8 | # Features 9 | This list will expand as libWiiPy is developed, but these features are currently available: 10 | - TMD and Ticket parsing/editing (`.tmd`, `.tik`) 11 | - Title parsing/editing, including content encryption/decryption (both retail and development) 12 | - WAD file parsing/editing (`.wad`) 13 | - Downloading titles and their components from the NUS 14 | - Certificate, TMD, and Ticket signature verification 15 | - Packing and unpacking U8 archives (`.app`, `.arc`) 16 | - Decompressing ASH files (`.ash`, both the standard variants and the variants found in My Pokémon Ranch) 17 | - Compressing/Decompressing LZ77-compressed files 18 | - IOS patching 19 | - NAND-related functionality: 20 | - EmuNAND title management (currently requires an existing EmuNAND) 21 | - `content.map` parsing/editing 22 | - `setting.txt` parsing/editing 23 | - `uid.sys` parsing/editing 24 | - Limited channel banner parsing/editing 25 | - Assorted miscellaneous features used to make the other core features possible 26 | 27 | For a more detailed look at what's available in libWiiPy, check out our [API docs](https://ninjacheetah.github.io/libWiiPy). 28 | 29 | # Usage 30 | The easiest way to get libWiiPy for your project is to install the latest version of the library from PyPI, as shown below. 31 | ```sh 32 | pip install -U libWiiPy 33 | ``` 34 | Our PyPI project page can be found [here](https://pypi.org/project/libWiiPy/). 35 | 36 | Because libWiiPy is very early in development, you may want to use the latest version of the package via git instead, so that you have the latest features available. You can do that like this: 37 | ```sh 38 | pip install -U git+https://github.com/NinjaCheetah/libWiiPy 39 | ``` 40 | Please be aware that because libWiiPy is in a very early state right now, many features may be subject to change, and methods and properties available now have the potential to disappear in the future. 41 | 42 | For more tips on getting started, see our guide [here](https://ninjacheetah.github.io/libWiiPy/usage/installation.html). 43 | 44 | # Building 45 | To build this package locally, the steps are quite simple, and should apply to all platforms. Make sure you've set up your `venv` first! 46 | 47 | First, install the dependencies from `requirements.txt`: 48 | ```sh 49 | pip install -r requirements.txt 50 | ``` 51 | 52 | Then, build the package using the Python `build` module: 53 | ```sh 54 | python -m build 55 | ``` 56 | 57 | And that's all! You'll find your compiled pip package in `dist/`. 58 | 59 | # Special Thanks 60 | This project wouldn't be possible without the amazing people behind its predecessors and all of the people who have contributed to the documentation of the Wii's inner workings over at [WiiBrew](https://wiibrew.org). 61 | 62 | ## Special Thanks to People Behind Related Projects 63 | - Xuzz, SquidMan, megazig, Matt_P, Omega and The Lemon Man for creating Wii.py 64 | - Leathl for creating libWiiSharp 65 | - TheShadowEevee for maintaining libWiiSharp 66 | 67 | ## Special Thanks to WiiBrew Contributors 68 | Thank you to all of the contributors to the documentation on the WiiBrew pages that make this all understandable! Some of the key articles referenced are as follows: 69 | - [Title metadata](https://wiibrew.org/wiki/Title_metadata), for the documentation on how a TMD is structured 70 | - [WAD files](https://wiibrew.org/wiki/WAD_files), for the documentation on how a WAD is structured 71 | - [IOS history](https://wiibrew.org/wiki/IOS_history), for the documentation on IOS TIDs and how IOS is versioned 72 | 73 | ### One additional special thanks to [@DamiDoop](https://github.com/DamiDoop)! 74 | 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. 75 | -------------------------------------------------------------------------------- /src/libWiiPy/nand/sys.py: -------------------------------------------------------------------------------- 1 | # "nand/sys.py" from libWiiPy by NinjaCheetah & Contributors 2 | # https://github.com/NinjaCheetah/libWiiPy 3 | # 4 | # See https://wiibrew.org/wiki//sys/uid.sys for information about uid.sys. 5 | 6 | import io 7 | import binascii 8 | from typing import List 9 | from dataclasses import dataclass as _dataclass 10 | 11 | 12 | @_dataclass 13 | class _UidSysEntry: 14 | """ 15 | A _UidSysEntry object used to store an entry in uid.sys. Private class used by the sys module. 16 | 17 | Attributes 18 | ---------- 19 | title_id : str 20 | The Title ID of the title this entry corresponds with. 21 | uid : int 22 | The UID assigned to the title this entry corresponds with. 23 | """ 24 | title_id: str 25 | uid: int 26 | 27 | 28 | class UidSys: 29 | """ 30 | A UidSys object to parse and edit the uid.sys file stored in /sys/ on the Wii's NAND. This file is used to track all 31 | the titles that have been launched on a console. 32 | 33 | Attributes 34 | ---------- 35 | uid_entries : List[_UidSysEntry] 36 | The entries stored in the uid.sys file. 37 | """ 38 | 39 | def __init__(self) -> None: 40 | self.uid_entries: List[_UidSysEntry] = [] 41 | 42 | def load(self, uid_sys: bytes) -> None: 43 | """ 44 | Loads the raw data of uid.sys and parses it into a list of entries. 45 | 46 | Parameters 47 | ---------- 48 | uid_sys : bytes 49 | The data of a uid.sys file. 50 | """ 51 | # Sanity check to ensure the length is divisible by 12 bytes. If it isn't, then it is malformed. 52 | if (len(uid_sys) % 12) != 0: 53 | raise ValueError("The provided uid.sys appears to be corrupted!") 54 | entry_count = len(uid_sys) // 12 55 | with io.BytesIO(uid_sys) as uid_data: 56 | for i in range(entry_count): 57 | title_id = binascii.hexlify(uid_data.read(8)).decode() 58 | uid_data.seek(uid_data.tell() + 2) 59 | uid = int.from_bytes(uid_data.read(2)) 60 | self.uid_entries.append(_UidSysEntry(title_id, uid)) 61 | 62 | def dump(self) -> bytes: 63 | """ 64 | Dumps the UidSys object back into a uid.sys file. 65 | 66 | Returns 67 | ------- 68 | bytes 69 | The raw data of the uid.sys file. 70 | """ 71 | uid_data = b'' 72 | for record in self.uid_entries: 73 | uid_data += binascii.unhexlify(record.title_id.encode()) 74 | uid_data += b'\x00' * 2 75 | uid_data += int.to_bytes(record.uid, 2) 76 | return uid_data 77 | 78 | def add(self, title_id: str | bytes) -> int: 79 | """ 80 | Adds a new Title ID to the uid.sys file and returns the UID assigned to that title. The new entry will only 81 | be added if the provided Title ID doesn't already have an assigned UID. 82 | 83 | Parameters 84 | ---------- 85 | title_id : str, bytes 86 | The Title ID to add. 87 | 88 | Returns 89 | ------- 90 | int 91 | The UID assigned to the new Title ID. 92 | """ 93 | if type(title_id) is bytes: 94 | # This catches the format b'\x00\x00\x00\x01\x00\x00\x00\x02' 95 | if len(title_id) == 8: 96 | title_id_converted = binascii.hexlify(title_id).decode() 97 | # If it isn't one of those lengths, it cannot possibly be valid, so reject it. 98 | else: 99 | raise ValueError("Title ID is not valid!") 100 | # Allow for a string like "0000000100000002" 101 | elif type(title_id) is str: 102 | if len(title_id) != 16: 103 | raise ValueError("Title ID is not valid!") 104 | title_id_converted = title_id 105 | else: 106 | raise TypeError("Title ID type is not valid! It must be either type str or bytes.") 107 | # Ensure this TID hasn't already been assigned a UID. If it has, just exit early and return the UID. 108 | if self.uid_entries.count != 0: 109 | for entry in self.uid_entries: 110 | if entry.title_id == title_id_converted: 111 | return entry.uid 112 | # Generate the new UID by incrementing the current highest UID by 1. 113 | try: 114 | new_uid = self.uid_entries[-1].uid + 1 115 | except IndexError: 116 | new_uid = 4096 117 | self.uid_entries.append(_UidSysEntry(title_id_converted, new_uid)) 118 | return new_uid 119 | 120 | def create(self) -> None: 121 | """ 122 | Creates a new uid.sys file and initializes it with the standard first entry of 1-2 with UID 4096. This allows 123 | for setting up a uid.sys file without having to load an existing one. 124 | """ 125 | if len(self.uid_entries) != 0: 126 | raise Exception("A uid.sys file appears to already exist!") 127 | self.add("0000000100000002") 128 | -------------------------------------------------------------------------------- /src/libWiiPy/nand/setting.py: -------------------------------------------------------------------------------- 1 | # "nand/setting.py" from libWiiPy by NinjaCheetah & Contributors 2 | # https://github.com/NinjaCheetah/libWiiPy 3 | # 4 | # See https://wiibrew.org/wiki//title/00000001/00000002/data/setting.txt for information about setting.txt. 5 | 6 | import io 7 | from typing import List 8 | from ..shared import _pad_bytes 9 | 10 | 11 | _KEY = 0x73B5DBFA 12 | 13 | class SettingTxt: 14 | """ 15 | A SettingTxt object that allows for decrypting and then parsing a setting.txt file from the Wii. 16 | 17 | Attributes 18 | ---------- 19 | area : str 20 | The region of the System Menu this file matches with. 21 | model : str 22 | The model of the console, usually RVL-001 or RVL-101. 23 | dvd : int 24 | Unknown, might have to do with indicating support for scrapped DVD playback capabilities. 25 | mpch : str 26 | Unknown, generally accepted value is "0x7FFE". 27 | code : str 28 | Unknown code, may match with manufacturer code in serial number? 29 | serial_number : str 30 | Serial number of the console. 31 | video : str 32 | Video mode, either NTSC or PAL. 33 | game : str 34 | Another region code, possibly set by the hidden region select channel. 35 | """ 36 | def __init__(self) -> None: 37 | self.area: str = "" 38 | self.model: str = "" 39 | self.dvd: int = 0 40 | self.mpch: str = "" # What does this mean, Movie Player Channel? It's also a hex string, it seems. 41 | self.code: str = "" 42 | self.serial_number: str = "" 43 | self.video: str = "" 44 | self.game: str = "" 45 | 46 | def load(self, setting_txt: bytes) -> None: 47 | """ 48 | Loads the raw data of an encrypted setting.txt file and decrypts it to parse its arguments 49 | 50 | Parameters 51 | ---------- 52 | setting_txt : bytes 53 | The data of an encrypted setting.txt file. 54 | """ 55 | with io.BytesIO(setting_txt) as setting_data: 56 | global _KEY # I still don't actually know what *kind* of encryption this is. 57 | setting_txt_dec: List[int] = [] 58 | for i in range(0, 256): 59 | setting_txt_dec.append(int.from_bytes(setting_data.read(1)) ^ (_KEY & 0xff)) 60 | _KEY = (_KEY << 1) | (_KEY >> 31) 61 | setting_txt_bytes = bytes(setting_txt_dec) 62 | try: 63 | setting_str = setting_txt_bytes.decode('utf-8') 64 | except UnicodeDecodeError: 65 | last_newline_pos = setting_txt_bytes.rfind(b'\n') # This makes sure we don't try to decode any garbage data. 66 | setting_str = setting_txt_bytes[:last_newline_pos + 1].decode('utf-8') 67 | self.load_decrypted(setting_str) 68 | 69 | def load_decrypted(self, setting_txt: str) -> None: 70 | """ 71 | Loads the raw data of a decrypted setting.txt file and parses its arguments 72 | 73 | Parameters 74 | ---------- 75 | setting_txt : str 76 | The data of a decrypted setting.txt file. 77 | """ 78 | setting_dict = {} 79 | # Iterate over every key in the file to create a dictionary. 80 | for line in setting_txt.splitlines(): 81 | line = line.strip() 82 | if line is not None: 83 | key, value = line.split('=', 1) 84 | setting_dict[key.strip()] = value.strip() 85 | # Load the values from the dictionary into the object. 86 | self.area = setting_dict["AREA"] 87 | self.model = setting_dict["MODEL"] 88 | self.dvd = int(setting_dict["DVD"]) 89 | self.mpch = setting_dict["MPCH"] 90 | self.code = setting_dict["CODE"] 91 | self.serial_number = setting_dict["SERNO"] 92 | self.video = setting_dict["VIDEO"] 93 | self.game = setting_dict["GAME"] 94 | 95 | def dump(self) -> bytes: 96 | """ 97 | Dumps the SettingTxt object back into an encrypted bytes that the Wii can load. 98 | 99 | Returns 100 | ------- 101 | bytes 102 | The setting.txt file as encrypted bytes. 103 | """ 104 | setting_str = self.dump_decrypted() 105 | setting_txt_dec = setting_str.encode() 106 | global _KEY 107 | # This could probably be made more efficient somehow. 108 | setting_txt_enc: List[int] = [] 109 | with io.BytesIO(setting_txt_dec) as setting_data: 110 | for i in range(0, len(setting_txt_dec)): 111 | setting_txt_enc.append(int.from_bytes(setting_data.read(1)) ^ (_KEY & 0xff)) 112 | _KEY = (_KEY << 1) | (_KEY >> 31) 113 | setting_txt_bytes = _pad_bytes(bytes(setting_txt_enc), 256) 114 | return setting_txt_bytes 115 | 116 | def dump_decrypted(self) -> str: 117 | """ 118 | Dumps the SettingTxt object into a decrypted string. 119 | 120 | Returns 121 | ------- 122 | str 123 | The setting.txt file as decrypted text. 124 | """ 125 | # Write the keys back into a text file that can then be manually edited or re-encrypted. 126 | setting_txt = "" 127 | setting_txt += f"AREA={self.area}\r\n" 128 | setting_txt += f"MODEL={self.model}\r\n" 129 | setting_txt += f"DVD={self.dvd}\r\n" 130 | setting_txt += f"MPCH={self.mpch}\r\n" 131 | setting_txt += f"CODE={self.code}\r\n" 132 | setting_txt += f"SERNO={self.serial_number}\r\n" 133 | setting_txt += f"VIDEO={self.video}\r\n" 134 | setting_txt += f"GAME={self.game}\r\n" 135 | return setting_txt 136 | -------------------------------------------------------------------------------- /src/libWiiPy/title/crypto.py: -------------------------------------------------------------------------------- 1 | # "title/crypto.py" from libWiiPy by NinjaCheetah & Contributors 2 | # https://github.com/NinjaCheetah/libWiiPy 3 | 4 | import binascii 5 | import struct 6 | from .commonkeys import get_common_key 7 | from Crypto.Cipher import AES as _AES 8 | 9 | 10 | def _convert_tid_to_iv(title_id: str | bytes) -> bytes: 11 | # Converts a Title ID in various formats into the format required to act as an IV. Private function used by other 12 | # crypto functions. 13 | if type(title_id) is bytes: 14 | # This catches the format b'0000000100000002' 15 | if len(title_id) == 16: 16 | title_key_iv = binascii.unhexlify(title_id) 17 | # This catches the format b'\x00\x00\x00\x01\x00\x00\x00\x02' 18 | elif len(title_id) == 8: 19 | title_key_iv = title_id 20 | # If it isn't one of those lengths, it cannot possibly be valid, so reject it. 21 | else: 22 | raise ValueError("Title ID is not valid!") 23 | # Allow for a string like "0000000100000002" 24 | elif type(title_id) is str: 25 | title_key_iv = binascii.unhexlify(title_id) 26 | # If the Title ID isn't bytes or a string, it isn't valid and is rejected. 27 | else: 28 | raise TypeError("Title ID type is not valid! It must be either type str or bytes.") 29 | return title_key_iv 30 | 31 | 32 | def decrypt_title_key(title_key_enc: bytes, common_key_index: int, title_id: bytes | str, dev=False) -> bytes: 33 | """ 34 | Gets the decrypted version of the encrypted Title Key provided. 35 | 36 | Requires the index of the common key to use, and the Title ID of the title that the Title Key is for. 37 | 38 | Parameters 39 | ---------- 40 | title_key_enc : bytes 41 | The encrypted Title Key. 42 | common_key_index : int 43 | The index of the common key used to encrypt the Title Key. 44 | title_id : bytes, str 45 | The Title ID of the title that the key is for. 46 | dev : bool 47 | Whether the Title Key is encrypted with the development key or not. 48 | 49 | Returns 50 | ------- 51 | bytes 52 | The decrypted Title Key. 53 | """ 54 | # Load the correct common key for the title. 55 | common_key = get_common_key(common_key_index, dev) 56 | # Convert the IV into the correct format based on the type provided. 57 | title_key_iv = _convert_tid_to_iv(title_id) 58 | # The IV will always be in the same format by this point, so add the last 8 bytes. 59 | title_key_iv = title_key_iv + (b'\x00' * 8) 60 | # Create a new AES object with the values provided. 61 | aes = _AES.new(common_key, _AES.MODE_CBC, title_key_iv) 62 | # Decrypt the Title Key using the AES object. 63 | title_key = aes.decrypt(title_key_enc) 64 | return title_key 65 | 66 | 67 | def encrypt_title_key(title_key_dec: bytes, common_key_index: int, title_id: bytes | str, dev=False) -> bytes: 68 | """ 69 | Encrypts the provided Title Key with the selected common key. 70 | 71 | Requires the index of the common key to use, and the Title ID of the title that the Title Key is for. 72 | 73 | Parameters 74 | ---------- 75 | title_key_dec : bytes 76 | The decrypted Title Key. 77 | common_key_index : int 78 | The index of the common key used to encrypt the Title Key. 79 | title_id : bytes, str 80 | The Title ID of the title that the key is for. 81 | dev : bool 82 | Whether the Title Key is encrypted with the development key or not. 83 | 84 | Returns 85 | ------- 86 | bytes 87 | An encrypted Title Key. 88 | """ 89 | # Load the correct common key for the title. 90 | common_key = get_common_key(common_key_index, dev) 91 | # Convert the IV into the correct format based on the type provided. 92 | title_key_iv = _convert_tid_to_iv(title_id) 93 | # The IV will always be in the same format by this point, so add the last 8 bytes. 94 | title_key_iv = title_key_iv + (b'\x00' * 8) 95 | # Create a new AES object with the values provided. 96 | aes = _AES.new(common_key, _AES.MODE_CBC, title_key_iv) 97 | # Encrypt Title Key using the AES object. 98 | title_key = aes.encrypt(title_key_dec) 99 | return title_key 100 | 101 | 102 | def decrypt_content(content_enc, title_key, content_index, content_length) -> bytes: 103 | """ 104 | Gets the decrypted version of the encrypted content. 105 | 106 | This requires the index of the content to decrypt as it is used as the IV, as well as the content length to adjust 107 | padding as necessary. 108 | 109 | Parameters 110 | ---------- 111 | content_enc : bytes 112 | The encrypted content. 113 | title_key : bytes 114 | The Title Key for the title the content is from. 115 | content_index : int 116 | The index in the TMD's content record of the content being decrypted. 117 | content_length : int 118 | The length in the TMD's content record of the content being decrypted. 119 | 120 | Returns 121 | ------- 122 | bytes 123 | The decrypted content. 124 | """ 125 | # Generate the IV from the Content Index of the content to be decrypted. 126 | content_index_bin = struct.pack('>H', content_index) 127 | while len(content_index_bin) < 16: 128 | content_index_bin += b'\x00' 129 | # Align content to 16 bytes to ensure that it works with AES encryption. 130 | if (len(content_enc) % 16) != 0: 131 | content_enc = content_enc + (b'\x00' * (16 - (len(content_enc) % 16))) 132 | # Create a new AES object with the values provided, with the content's unique ID as the IV. 133 | aes = _AES.new(title_key, _AES.MODE_CBC, content_index_bin) 134 | # Decrypt the content using the AES object. 135 | content_dec = aes.decrypt(content_enc) 136 | # Trim additional bytes that may have been added so the content is the correct size. 137 | content_dec = content_dec[:content_length] 138 | return content_dec 139 | 140 | 141 | def encrypt_content(content_dec, title_key, content_index) -> bytes: 142 | """ 143 | Gets the encrypted version of the decrypted content. 144 | 145 | This requires the index of the content to encrypt as it is used as the IV, as well as the content length to adjust 146 | padding as necessary. 147 | 148 | Parameters 149 | ---------- 150 | content_dec : bytes 151 | The decrypted content. 152 | title_key : bytes 153 | The Title Key for the title the content is from. 154 | content_index : int 155 | The index in the TMD's content record of the content being decrypted. 156 | 157 | Returns 158 | ------- 159 | bytes 160 | The encrypted content. 161 | """ 162 | # Generate the IV from the Content Index of the content to be decrypted. 163 | content_index_bin = struct.pack('>H', content_index) 164 | while len(content_index_bin) < 16: 165 | content_index_bin += b'\x00' 166 | # Calculate the intended size of the encrypted content. 167 | enc_size = len(content_dec) + (16 - (len(content_dec) % 16)) 168 | # Align content to 16 bytes to ensure that it works with AES encryption. 169 | if (len(content_dec) % 16) != 0: 170 | content_dec = content_dec + (b'\x00' * (16 - (len(content_dec) % 16))) 171 | # Create a new AES object with the values provided, with the content's unique ID as the IV. 172 | aes = _AES.new(title_key, _AES.MODE_CBC, content_index_bin) 173 | # Encrypt the content using the AES object. 174 | content_enc = aes.encrypt(content_dec) 175 | # Trim down the encrypted content. 176 | content_enc = content_enc[:enc_size] 177 | return content_enc 178 | -------------------------------------------------------------------------------- /docs/source/titles/extracting-titles.md: -------------------------------------------------------------------------------- 1 | # Extracting Titles from WAD Files 2 | 3 | One of the most common uses for libWiiPy's title subpackage is extracting WAD files so that you can edit their contents. This can open up the doors to modding, like with the [famous DVD image](https://ncxprogramming.com/2023/06/19/wii-dvd-p3.html) in the Wii Menu that actually kicked this project off, or other projects like datamining. 4 | 5 | :::{note} 6 | This guide assumes that you already have a WAD file that you'd like to extract, and that this WAD file doesn't use a personalized ticket, as titles with personalized tickets are not as easy to manipulate. WADs like that aren't very common, as most WADs created from the NUS, dumped from a console, or obtained via other methods will not have this type of ticket, so if in doubt, it will probably work fine. 7 | 8 | If you don't currently have a WAD file, you may want to skip ahead to first to obtain one for a free title first. 9 | ::: 10 | 11 | :::{hint} 12 | If you've gotten here, but you're just looking for a tool to do all of this rather than a guide on how to write your own code, you're probably looking for something like [WiiPy](https://github.com/NinjaCheetah/WiiPy). WiiPy is a command line tool that covers all of libWiiPy's features, and is also made by NinjaCheetah. 13 | ::: 14 | 15 | With all of that out of the way, let's begin! 16 | 17 | ## Loading the WAD 18 | 19 | The first thing we'll do is import libWiiPy and load up our file: 20 | ```pycon 21 | >>> import libWiiPy 22 | >>> wad_data = open("file.wad").read() 23 | >>> 24 | ``` 25 | 26 | Then, we can create a new WAD object, and load our data into it: 27 | ```pycon 28 | >>> wad = libWiiPy.title.WAD() 29 | >>> wad.load(wad_data) 30 | >>> 31 | ``` 32 | 33 | And viola! We have a WAD object that we can use to get each separate part of our title. 34 | 35 | ## Picking the WAD Apart 36 | 37 | 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. 38 | 39 | ### The TMD 40 | 41 | To get the TMD, let's create a new TMD object, and then use the method `get_tmd_data()` on our WAD object as the source for our TMD data: 42 | ```pycon 43 | >>> tmd = libWiiPy.title.TMD() 44 | >>> tmd.load(wad.get_tmd_data()) 45 | >>> 46 | ``` 47 | 48 | And now, just like in our tutorial, we have a TMD object, and can get all the same data from it! 49 | 50 | ### The Ticket 51 | 52 | Next up, we need to get the Ticket. The process for getting the Ticket is very similar to getting the TMD. We'll create a new Ticket object, and then use the method `get_ticket_data()` to get the data: 53 | ```pycon 54 | >>> ticket = libWiiPy.title.Ticket() 55 | >>> ticket.load(wad.get_ticket_data()) 56 | >>> 57 | ``` 58 | 59 | Similarly to the TMD, we can use this Ticket object to get all the properties of a Ticket. This includes getting the decrypted version of the Ticket's encrypted Title Key. In fact, why don't we do that know? 60 | 61 | We can use a Ticket's `get_title_key()` method to decrypt the Title Key and return it. This uses the Ticket's `title_key_enc`, `common_key_index`, and `title_id` properties to get the IV and common key required to decrypt the Title Key. 62 | 63 | ```pycon 64 | >>> title_key = ticket.get_title_key() 65 | >>> 66 | ``` 67 | 68 | :::{danger} 69 | If the Ticket contained in your WAD is personalized, this Title Key will be invalid! `get_title_key()` won't return any error, as it has no way of validating the output, but the key will not work to decrypt any content. 70 | ::: 71 | 72 | ### The Contents 73 | 74 | Now that we have our TMD and Ticket extracted, we can get to work on extracting and decrypting the content. 75 | 76 | First, we'll need to create a new ContentRegion object, which requires sourcing the raw data of all the WAD's contents (which are stored as one continuous block) using `get_content_data()`, as well as the content records found in our TMD object. We can do this like so: 77 | 78 | ```pycon 79 | >>> content_region = libWiiPy.title.ContentRegion() 80 | >>> content_region.load(wad.get_content_data(), tmd.content_records) 81 | >>> 82 | ``` 83 | 84 | The content records from the TMD are used by the `content` module to parse the block of data that the contents are stored in so that they can be separated back out into individual files. Speaking of which, let's try extracting one (still in its encrypted form, for now) just to make sure everything is working. For this example, we'll use `get_enc_content_by_index()`, and get the content at index 0: 85 | 86 | ```pycon 87 | >>> encrypted_content = content_region.get_enc_content_by_index(0) 88 | >>> 89 | ``` 90 | 91 | As long as that's all good, that means our WAD's content has successfully been parsed, and we can start decrypting it! 92 | 93 | Let's try getting the same content again, the one at index 0, but this time in its decrypted form. We can use the method `get_content_by_index()` for this, which takes the index of the content we want, and the Title Key that we saved in the last step. 94 | ```pycon 95 | >>> decrypted_content = content_region.get_content_by_index(0, title_key) 96 | >>> 97 | ``` 98 | 99 | :::{error} 100 | If you get an error here saying that the hash of your decrypted content doesn't match the expected hash, then something has gone wrong. There are several possibilities, including your Ticket being personalized, causing you to get an invalid Title Key, your WAD having mismatched data, or your content being modified without the hash in the content record having been updated. 101 | ::: 102 | 103 | If you don't get any errors, then congratulations! You've just extracted your first decrypted content from a WAD! 104 | 105 | Now that we know things are working, why don't we speed things up a little by using the content region's `get_contents()` method, which will return a list of all the decrypted content: 106 | ```pycon 107 | >>> decrypted_content_list = content_region.get_contents(title_key) 108 | >>> 109 | ``` 110 | 111 | 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. 112 | 113 | ### The Certificate Chain 114 | 115 | 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: 116 | ```pycon 117 | >>> certificate_chain = libWiiPy.title.CertificateChain() 118 | >>> certificate_chain.load(wad.get_cert_data()) 119 | >>> 120 | ``` 121 | 122 | ### The Other Data 123 | 124 | 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: 125 | ```pycon 126 | >>> footer = wad.get_meta_data() 127 | >>> crl = wad.get_crl_data() 128 | >>> 129 | ``` 130 | 131 | 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. 132 | 133 | :::{note} 134 | 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). 135 | ::: 136 | 137 |
138 | 139 | Now, that might all seem a bit complicated. What if instead there was a way to manage a title using one object that handles all the individual components for you? Well, you're in luck! On top of the fairly low-level way to extract a WAD provided in this guide, libWiiPy also offers a higher-level method through the module. On the next page, we'll dive into the specifics, and how to use this module. 140 | -------------------------------------------------------------------------------- /src/libWiiPy/title/iospatcher.py: -------------------------------------------------------------------------------- 1 | # "title/iospatcher.py" from libWiiPy by NinjaCheetah & Contributors 2 | # https://github.com/NinjaCheetah/libWiiPy 3 | # 4 | # Module for applying patches to IOS WADs via a Title(). 5 | 6 | import io 7 | from .title import Title 8 | 9 | 10 | class IOSPatcher: 11 | """ 12 | An IOSPatcher object that allows for applying patches to IOS WADs loaded into Title objects. 13 | 14 | Attributes 15 | ---------- 16 | title : Title 17 | The loaded Title object to be patched. 18 | es_module_index : int 19 | The content index that ES resides in and where ES patches are applied. 20 | dip_module_index : int 21 | The content index that DIP resides in and where DIP patches are applied. -1 if DIP patches are not applied. 22 | """ 23 | def __init__(self) -> None: 24 | self.title: Title = Title() 25 | self.es_module_index: int = -1 26 | self.dip_module_index: int = -1 27 | 28 | def load(self, title: Title) -> None: 29 | """ 30 | Loads a Title object containing an IOS WAD and locates the content containing the ES module that needs to be 31 | patched. 32 | 33 | Parameters 34 | ---------- 35 | title : Title 36 | A Title object containing the IOS to be patched. 37 | """ 38 | # Check to ensure that this Title contains IOS. IOS always has a TID high of 00000001, and any TID low after 39 | # 00000002. 40 | tid = title.tmd.title_id 41 | if tid[:8] != "00000001" or tid[8:] == "00000001" or tid[8:] == "00000002": 42 | raise ValueError("This Title does not contain an IOS! Cannot load Title for patching.") 43 | 44 | # Now that we know this is IOS, we need to go ahead and check all of its contents until we find the one that 45 | # contains the ES module, since that's what we're patching. 46 | es_content_index = -1 47 | for content in range(len(title.content.content_records)): 48 | target_content = title.get_content_by_index(title.content.content_records[content].index) 49 | es_offset = target_content.find(b'\x45\x53\x3A') # This is looking for "ES:" 50 | if es_offset != -1: 51 | es_content_index = title.content.content_records[content].index 52 | break 53 | 54 | # If we get here with no content index, then ES wasn't found. That probably means that this isn't IOS. 55 | if es_content_index == -1: 56 | raise Exception("ES module could not be found! Please ensure that this is an intact copy of an IOS.") 57 | 58 | self.title = title 59 | self.es_module_index = es_content_index 60 | 61 | def dump(self) -> Title: 62 | """ 63 | Returns the patched Title object. 64 | 65 | Returns 66 | ------- 67 | Title 68 | The patched Title object. 69 | """ 70 | return self.title 71 | 72 | def patch_all(self) -> int: 73 | """ 74 | Applies all patches to patch in fakesigning, ES_Identify access, /dev/flash access, and the version downgrading 75 | patch. 76 | 77 | Returns 78 | ------- 79 | int 80 | The number of patches successfully applied. 81 | """ 82 | patch_count = 0 83 | patch_count += self.patch_fakesigning() 84 | patch_count += self.patch_es_identify() 85 | patch_count += self.patch_nand_access() 86 | patch_count += self.patch_version_downgrading() 87 | return patch_count 88 | 89 | def patch_fakesigning(self) -> int: 90 | """ 91 | Patches the trucha/fakesigning bug back into the IOS' ES module to allow it to accept fakesigned TMDs and 92 | Tickets. 93 | 94 | Returns 95 | ------- 96 | int 97 | The number of patches successfully applied. 98 | """ 99 | if self.es_module_index == -1: 100 | raise Exception("No valid IOS is loaded! Patching cannot continue.") 101 | 102 | target_content = self.title.get_content_by_index(self.es_module_index) 103 | 104 | patch_count = 0 105 | patch_sequences = [b'\x20\x07\x23\xa2', b'\x20\x07\x4b\x0b'] 106 | for sequence in patch_sequences: 107 | start_offset = target_content.find(sequence) 108 | if start_offset != -1: 109 | with io.BytesIO(target_content) as content_data: 110 | content_data.seek(start_offset + 1) 111 | content_data.write(b'\x00') 112 | content_data.seek(0) 113 | target_content = content_data.read() 114 | patch_count += 1 115 | 116 | self.title.set_content(target_content, self.es_module_index) 117 | 118 | return patch_count 119 | 120 | def patch_es_identify(self) -> int: 121 | """ 122 | Patches the ability to call ES_Identify back into the IOS' ES module to allow for changing the permissions of a 123 | title. 124 | 125 | Returns 126 | ------- 127 | int 128 | The number of patches successfully applied. 129 | """ 130 | if self.es_module_index == -1: 131 | raise Exception("No valid IOS is loaded! Patching cannot continue.") 132 | 133 | target_content = self.title.get_content_by_index(self.es_module_index) 134 | 135 | patch_count = 0 136 | patch_sequence = b'\x28\x03\xd1\x23' 137 | start_offset = target_content.find(patch_sequence) 138 | if start_offset != -1: 139 | with io.BytesIO(target_content) as content_data: 140 | content_data.seek(start_offset + 2) 141 | content_data.write(b'\x00\x00') 142 | content_data.seek(0) 143 | target_content = content_data.read() 144 | patch_count += 1 145 | 146 | self.title.set_content(target_content, self.es_module_index) 147 | 148 | return patch_count 149 | 150 | def patch_nand_access(self) -> int: 151 | """ 152 | Patches the ability to directly access /dev/flash back into the IOS' ES module to allow for raw access to the 153 | Wii's filesystem. 154 | 155 | Returns 156 | ------- 157 | int 158 | The number of patches successfully applied. 159 | """ 160 | if self.es_module_index == -1: 161 | raise Exception("No valid IOS is loaded! Patching cannot continue.") 162 | 163 | target_content = self.title.get_content_by_index(self.es_module_index) 164 | 165 | patch_count = 0 166 | patch_sequence = b'\x42\x8b\xd0\x01\x25\x66' 167 | start_offset = target_content.find(patch_sequence) 168 | if start_offset != -1: 169 | with io.BytesIO(target_content) as content_data: 170 | content_data.seek(start_offset + 2) 171 | content_data.write(b'\xe0') 172 | content_data.seek(0) 173 | target_content = content_data.read() 174 | patch_count += 1 175 | 176 | self.title.set_content(target_content, self.es_module_index) 177 | 178 | return patch_count 179 | 180 | def patch_version_downgrading(self) -> int: 181 | """ 182 | Patches the ability to downgrade installed titles into IOS' ES module. 183 | 184 | Returns 185 | ------- 186 | int 187 | The number of patches successfully applied. 188 | """ 189 | if self.es_module_index == -1: 190 | raise Exception("No valid IOS is loaded! Patching cannot continue.") 191 | 192 | target_content = self.title.get_content_by_index(self.es_module_index) 193 | 194 | patch_count = 0 195 | patch_sequence = b'\xd2\x01\x4e\x56' 196 | start_offset = target_content.find(patch_sequence) 197 | if start_offset != -1: 198 | with io.BytesIO(target_content) as content_data: 199 | content_data.seek(start_offset) 200 | content_data.write(b'\xe0') 201 | content_data.seek(0) 202 | target_content = content_data.read() 203 | patch_count += 1 204 | 205 | self.title.set_content(target_content, self.es_module_index) 206 | 207 | return patch_count 208 | 209 | def patch_drive_inquiry(self) -> int: 210 | """ 211 | Patches out IOS' drive inquiry on startup, allowing IOS to load without a disc drive. Only required/useful if 212 | you do not have a disc drive connected to your console. 213 | 214 | This drive inquiry patch is EXPERIMENTAL, and may introduce unexpected side effects on some consoles. 215 | 216 | Returns 217 | ------- 218 | int 219 | The number of patches successfully applied. 220 | """ 221 | if self.es_module_index == -1: 222 | raise Exception("No valid IOS is loaded! Patching cannot continue.") 223 | 224 | # This patch is applied to the DIP module rather than to ES, so we need to search the contents for the right one 225 | # first. 226 | for content in range(len(self.title.content.content_records)): 227 | target_content = self.title.get_content_by_index(self.title.content.content_records[content].index) 228 | dip_offset = target_content.find(b'\x44\x49\x50\x3a') # This is looking for "DIP:" 229 | if dip_offset != -1: 230 | self.dip_module_index = self.title.content.content_records[content].index 231 | break 232 | 233 | # If we get here with no content index, then DIP wasn't found. That probably means that this isn't IOS. 234 | if self.dip_module_index == -1: 235 | raise Exception("DIP module could not be found! Please ensure that this is an intact copy of an IOS.") 236 | 237 | target_content = self.title.get_content_by_index(self.dip_module_index) 238 | 239 | patch_count = 0 240 | patch_sequence = b'\x49\x4c\x23\x90\x68\x0a' # 49 4c 23 90 68 0a 241 | start_offset = target_content.find(patch_sequence) 242 | if start_offset != -1: 243 | with io.BytesIO(target_content) as content_data: 244 | content_data.seek(start_offset) 245 | content_data.write(b'\x20\x00\xe5\x38') 246 | content_data.seek(0) 247 | target_content = content_data.read() 248 | patch_count += 1 249 | 250 | self.title.set_content(target_content, self.dip_module_index) 251 | 252 | return patch_count 253 | -------------------------------------------------------------------------------- /src/libWiiPy/archive/ash.py: -------------------------------------------------------------------------------- 1 | # "archive/ash.py" from libWiiPy by NinjaCheetah & Contributors 2 | # https://github.com/NinjaCheetah/libWiiPy 3 | # 4 | # This code in particular is a direct translation of "ash-dec" from ASH0-tools. ASH0-tools is written by Garhoogin and 5 | # co-authored by NinjaCheetah. 6 | # https://github.com/NinjaCheetah/ASH0-tools 7 | # 8 | # See for details about the ASH compression format. 9 | 10 | import io 11 | from dataclasses import dataclass 12 | from typing import List 13 | 14 | 15 | @dataclass 16 | class _ASHBitReader: 17 | """ 18 | An _ASHBitReader class used to parse individual words in an ASH file. Private class used by the ASH module. 19 | 20 | Attributes 21 | ---------- 22 | src_data : list[int] 23 | The entire data of the ASH file being parsed, as a list of integers for each byte. 24 | size : int 25 | The size of the ASH file. 26 | src_pos : int 27 | The position in the src_data list currently being accessed. 28 | word : int 29 | The word currently being decompressed. 30 | bit_capacity : int 31 | tree_type : str 32 | What tree this bit reader is being used with. Used exclusively for debugging, as this value is only used in 33 | error messages. 34 | """ 35 | src_data: list[int] 36 | size: int 37 | src_pos: int 38 | word: int 39 | bit_capacity: int 40 | tree_type: str 41 | 42 | 43 | def _ash_bit_reader_feed_word(bit_reader: _ASHBitReader): 44 | # Ensure that there's enough data to read en entire word, then if there is, read one. 45 | if not bit_reader.src_pos + 4 <= bit_reader.size: 46 | print(bit_reader.src_pos) 47 | raise ValueError("Invalid ASH data! Cannot decompress.") 48 | bit_reader.word = int.from_bytes(bit_reader.src_data[bit_reader.src_pos:bit_reader.src_pos + 4], 'big') 49 | bit_reader.bit_capacity = 0 50 | bit_reader.src_pos += 4 51 | 52 | 53 | def _ash_bit_reader_init(bit_reader: _ASHBitReader, src: list[int], size: int, start_pos: int): 54 | # Load data into a bit reader, then have it read its first word. 55 | bit_reader.src_data = src 56 | bit_reader.size = size 57 | bit_reader.src_pos = start_pos 58 | _ash_bit_reader_feed_word(bit_reader) 59 | 60 | 61 | def _ash_bit_reader_read_bit(bit_reader: _ASHBitReader): 62 | # Reads the starting bit of the current word in the provided bit reader. If the capacity is at 31, then we've 63 | # shifted through the entire word, so a new one should be fed. If not, increase the capacity by one and shift the 64 | # current word left. 65 | bit = bit_reader.word >> 31 66 | if bit_reader.bit_capacity == 31: 67 | _ash_bit_reader_feed_word(bit_reader) 68 | else: 69 | bit_reader.bit_capacity += 1 70 | bit_reader.word = (bit_reader.word << 1) & 0xFFFFFFFF # This simulates a 32-bit integer. 71 | 72 | return bit 73 | 74 | 75 | def _ash_bit_reader_read_bits(bit_reader: _ASHBitReader, num_bits: int): 76 | # Reads a series of bytes from the current word in the supplied bit reader. 77 | bits: int 78 | next_bit = bit_reader.bit_capacity + num_bits 79 | 80 | if next_bit <= 32: 81 | bits = bit_reader.word >> (32 - num_bits) 82 | if next_bit != 32: 83 | bit_reader.word = (bit_reader.word << num_bits) & 0xFFFFFFFF # This simulates a 32-bit integer (again). 84 | bit_reader.bit_capacity += num_bits 85 | else: 86 | _ash_bit_reader_feed_word(bit_reader) 87 | else: 88 | bits = bit_reader.word >> (32 - num_bits) 89 | _ash_bit_reader_feed_word(bit_reader) 90 | bits |= (bit_reader.word >> (64 - next_bit)) 91 | bit_reader.word = (bit_reader.word << (next_bit - 32)) & 0xFFFFFFFF # Simulate 32-bit int. 92 | bit_reader.bit_capacity = next_bit - 32 93 | 94 | return bits 95 | 96 | 97 | def _ash_read_tree(bit_reader: _ASHBitReader, width: int, left_tree: List[int], right_tree: List[int]): 98 | # Read either the symbol or distance tree from the ASH file, and return the root of that tree. 99 | work = [0] * (2 * (1 << width)) 100 | work_pos = 0 101 | 102 | r23 = 1 << width 103 | tree_root = 0 104 | num_nodes = 0 105 | 106 | while True: 107 | if _ash_bit_reader_read_bit(bit_reader) != 0: 108 | work[work_pos] = (r23 | 0x80000000) 109 | work_pos += 1 110 | work[work_pos] = (r23 | 0x40000000) 111 | work_pos += 1 112 | num_nodes += 2 113 | r23 += 1 114 | else: 115 | tree_root = _ash_bit_reader_read_bits(bit_reader, width) 116 | while True: 117 | work_pos -= 1 118 | node_value = work[work_pos] 119 | idx = node_value & 0x3FFFFFFF 120 | num_nodes -= 1 121 | try: 122 | if node_value & 0x80000000: 123 | right_tree[idx] = tree_root 124 | tree_root = idx 125 | else: 126 | left_tree[idx] = tree_root 127 | break 128 | except IndexError: 129 | raise ValueError("Decompression failed while reading " + bit_reader.tree_type + " tree! Incorrect " 130 | "leaf width may have been used. Try using a different number of bits for the " + 131 | bit_reader.tree_type + " tree leaves.") 132 | # Simulate a do-while loop. 133 | if num_nodes == 0: 134 | break 135 | # Also a do-while. 136 | if num_nodes == 0: 137 | break 138 | 139 | return tree_root 140 | 141 | 142 | def _decompress_ash(input_data: list[int], size: int, sym_bits: int, dist_bits: int): 143 | # Get the size of the decompressed data by reading the second 4 bytes of the file and masking the first one out. 144 | decompressed_size = int.from_bytes(input_data[0x4:0x8]) & 0x00FFFFFF 145 | # Array of decompressed data and the position in that array that we're at. Mimics the memory pointer from the 146 | # original C source. 147 | out_buffer = [0] * decompressed_size 148 | out_buffer_pos = 0 149 | # Create two empty bit readers, and then initialize them at two different positions for the two trees. 150 | bit_reader1 = _ASHBitReader([0], 0, 0, 0, 0, "distance") 151 | _ash_bit_reader_init(bit_reader1, input_data, size, int.from_bytes(input_data[0x8:0xC], byteorder='big')) 152 | bit_reader2 = _ASHBitReader([0], 0, 0, 0, 0, "symbol") 153 | _ash_bit_reader_init(bit_reader2, input_data, size, 0xC) 154 | # Calculate the max for the symbol and distance trees based on the bit lengths that were passed. Then, allocate the 155 | # arrays for all the trees based on that maximum. 156 | sym_max = 1 << sym_bits 157 | dist_max = 1 << dist_bits 158 | sym_left_tree = [0] * (2 * sym_max - 1) 159 | sym_right_tree = [0] * (2 * sym_max - 1) 160 | dist_left_tree = [0] * (2 * dist_max - 1) 161 | dist_right_tree = [0] * (2 * dist_max - 1) 162 | # Read the trees to find the symbol and distance tree roots. 163 | sym_root = _ash_read_tree(bit_reader2, sym_bits, sym_left_tree, sym_right_tree) 164 | dist_root = _ash_read_tree(bit_reader1, dist_bits, dist_left_tree, dist_right_tree) 165 | # Main decompression loop. 166 | while True: 167 | sym = sym_root 168 | while sym >= sym_max: 169 | if _ash_bit_reader_read_bit(bit_reader2) != 0: 170 | sym = sym_right_tree[sym] 171 | else: 172 | sym = sym_left_tree[sym] 173 | if sym < 0x100: 174 | out_buffer[out_buffer_pos] = sym 175 | out_buffer_pos += 1 176 | decompressed_size -= 1 177 | else: 178 | dist_sym = dist_root 179 | while dist_sym >= dist_max: 180 | if _ash_bit_reader_read_bit(bit_reader1) != 0: 181 | dist_sym = dist_right_tree[dist_sym] 182 | else: 183 | dist_sym = dist_left_tree[dist_sym] 184 | copy_len = (sym - 0x100) + 3 185 | srcp_pos = out_buffer_pos - dist_sym - 1 186 | # Check to make sure we aren't going to exceed the specified decompressed size. 187 | if not copy_len <= decompressed_size: 188 | raise ValueError("Invalid ASH data! Cannot decompress.") 189 | 190 | decompressed_size -= copy_len 191 | while copy_len > 0: 192 | out_buffer[out_buffer_pos] = out_buffer[srcp_pos] 193 | out_buffer_pos += 1 194 | srcp_pos += 1 195 | copy_len -= 1 196 | # Simulate a do-while loop. 197 | if decompressed_size == 0: 198 | break 199 | 200 | return out_buffer 201 | 202 | 203 | def decompress_ash(ash_data: bytes, sym_tree_bits: int = 9, dist_tree_bits: int = 11) -> bytes: 204 | """ 205 | Decompresses the data of an ASH file and returns the decompressed data. 206 | 207 | With the default parameters, this function can decompress ASH files found in the files of the Wii Menu and Animal 208 | Crossing: City Folk. Some ASH files, notably the ones found in the WiiWare title My Pokémon Ranch, require setting 209 | dist_tree_bits to 15 instead for a successful decompression. If an ASH file is failing to decompress with the 210 | default options, trying a dist_tree_bits value of 15 will likely fix it. No other leaf sizes are known to exist, 211 | however they might be out there. 212 | 213 | Parameters 214 | ---------- 215 | ash_data : bytes 216 | The data for the ASH file to decompress. 217 | sym_tree_bits : int, option 218 | Number of bits for each leaf in the symbol tree. Defaults to 9. 219 | dist_tree_bits : int, option 220 | Number of bits for each leaf in the distance tree. Defaults to 11. 221 | """ 222 | # Check the magic number to make sure this is an ASH file. 223 | with io.BytesIO(ash_data) as ash_data2: 224 | ash_magic = ash_data2.read(4) 225 | if ash_magic != b'\x41\x53\x48\x30': 226 | raise TypeError("This is not a valid ASH file!") 227 | # Begin decompression. Convert the compressed data to an array of ints for processing, then convert the returned 228 | # decompressed data back into bytes to return it. 229 | ash_size = len(ash_data) 230 | ash_data_int = [byte for byte in ash_data] 231 | decompressed_data = _decompress_ash(ash_data_int, ash_size, sym_tree_bits, dist_tree_bits) 232 | decompressed_data_bin = bytes(decompressed_data) 233 | 234 | return decompressed_data_bin 235 | -------------------------------------------------------------------------------- /src/libWiiPy/archive/lz77.py: -------------------------------------------------------------------------------- 1 | # "archive/lz77.py" from libWiiPy by NinjaCheetah & Contributors 2 | # https://github.com/NinjaCheetah/libWiiPy 3 | # 4 | # See https://wiibrew.org/wiki/LZ77 for details about the LZ77 compression format. 5 | 6 | import io 7 | from dataclasses import dataclass as _dataclass 8 | from typing import List, Tuple 9 | 10 | 11 | _LZ_MIN_DISTANCE = 0x01 # Minimum distance for each reference. 12 | _LZ_MAX_DISTANCE = 0x1000 # Maximum distance for each reference. 13 | _LZ_MIN_LENGTH = 0x03 # Minimum length for each reference. 14 | _LZ_MAX_LENGTH = 0x12 # Maximum length for each reference. 15 | 16 | 17 | @_dataclass 18 | class _LZNode: 19 | dist: int = 0 20 | len: int = 0 21 | weight: int = 0 22 | 23 | 24 | def _compress_compare_bytes(buffer: List[int], offset1: int, offset2: int, abs_len_max: int) -> int: 25 | # Compare bytes up to the maximum length we can match. Start by comparing the first 3 bytes, since that's the 26 | # minimum match length and this allows for a more optimized early exit. 27 | num_matched = 0 28 | while num_matched < abs_len_max: 29 | if buffer[offset1 + num_matched] != buffer[offset2 + num_matched]: 30 | break 31 | num_matched += 1 32 | return num_matched 33 | 34 | 35 | def _compress_search_matches_optimized(buffer: List[int], pos: int) -> Tuple[int, int]: 36 | bytes_left = len(buffer) - pos 37 | global _LZ_MAX_DISTANCE, _LZ_MIN_LENGTH, _LZ_MAX_LENGTH, _LZ_MIN_DISTANCE 38 | # Default to only looking back 4096 bytes, unless we've moved fewer than 4096 bytes, in which case we should 39 | # only look as far back as we've gone. 40 | max_dist = min(_LZ_MAX_DISTANCE, pos) 41 | # Default to only matching up to 18 bytes, unless fewer than 18 bytes remain, in which case we can only match 42 | # up to that many bytes. 43 | max_len = min(_LZ_MAX_LENGTH, bytes_left) 44 | # Log the longest match we found and its offset. 45 | biggest_match, biggest_match_pos = 0, 0 46 | # Search for matches. 47 | for i in range(_LZ_MIN_DISTANCE, max_dist + 1): 48 | num_matched = _compress_compare_bytes(buffer, pos - i, pos, max_len) 49 | if num_matched > biggest_match: 50 | biggest_match = num_matched 51 | biggest_match_pos = i 52 | if biggest_match == max_len: 53 | break 54 | return biggest_match, biggest_match_pos 55 | 56 | 57 | def _compress_search_matches_greedy(buffer: List[int], pos: int) -> Tuple[int, int]: 58 | # Finds and returns the first valid match, rather that finding the best one. 59 | bytes_left = len(buffer) - pos 60 | global _LZ_MAX_DISTANCE, _LZ_MAX_LENGTH, _LZ_MIN_DISTANCE 61 | # Default to only looking back 4096 bytes, unless we've moved fewer than 4096 bytes, in which case we should 62 | # only look as far back as we've gone. 63 | max_dist = min(_LZ_MAX_DISTANCE, pos) 64 | # Default to only matching up to 18 bytes, unless fewer than 18 bytes remain, in which case we can only match 65 | # up to that many bytes. 66 | max_len = min(_LZ_MAX_LENGTH, bytes_left) 67 | match, match_pos = 0, 0 68 | for i in range(_LZ_MIN_DISTANCE, max_dist + 1): 69 | match = _compress_compare_bytes(buffer, pos - i, pos, max_len) 70 | match_pos = i 71 | if match >= _LZ_MIN_LENGTH or match == max_len: 72 | break 73 | return match, match_pos 74 | 75 | 76 | def _compress_node_is_ref(node: _LZNode) -> bool: 77 | return node.len >= _LZ_MIN_LENGTH 78 | 79 | 80 | def _compress_get_node_cost(length: int) -> int: 81 | if length >= _LZ_MIN_LENGTH: 82 | num_bytes = 2 83 | else: 84 | num_bytes = 1 85 | return 1 + (num_bytes * 8) 86 | 87 | 88 | def _compress_lz77_optimized(data: bytes) -> bytes: 89 | # Optimized compressor based around a node graph that finds optimal string matches. Originally the default 90 | # implementation, but unfortunately it's very slow. 91 | nodes = [_LZNode() for _ in range(len(data))] 92 | # Iterate over the uncompressed data, starting from the end. 93 | pos = len(data) 94 | global _LZ_MAX_LENGTH, _LZ_MIN_LENGTH, _LZ_MIN_DISTANCE 95 | data_list = list(data) 96 | while pos: 97 | pos -= 1 98 | node = nodes[pos] 99 | # Limit the maximum search length when we're near the end of the file. 100 | max_search_len = min(_LZ_MAX_LENGTH, len(data_list) - pos) 101 | if max_search_len < _LZ_MIN_DISTANCE: 102 | max_search_len = 1 103 | # Initialize as 1 for each, since that's all we could use if we weren't compressing. 104 | length, dist = 1, 1 105 | if max_search_len >= _LZ_MIN_LENGTH: 106 | length, dist = _compress_search_matches_optimized(data_list, pos) 107 | # Treat as direct bytes if it's too short to copy. 108 | if length == 0 or length < _LZ_MIN_LENGTH: 109 | length = 1 110 | # If the node goes to the end of the file, the weight is the cost of the node. 111 | if (pos + length) == len(data_list): 112 | node.len = length 113 | node.dist = dist 114 | node.weight = _compress_get_node_cost(length) 115 | # Otherwise, search for possible matches and determine the one with the best cost. 116 | else: 117 | weight_best = 0xFFFFFFFF # This was originally UINT_MAX, but that isn't a thing here so 32-bit it is! 118 | len_best = 1 119 | while length: 120 | weight_next = nodes[pos + length].weight 121 | weight = _compress_get_node_cost(length) + weight_next 122 | if weight < weight_best: 123 | len_best = length 124 | weight_best = weight 125 | length -= 1 126 | if length != 0 and length < _LZ_MIN_LENGTH: 127 | length = 1 128 | node.len = len_best 129 | node.dist = dist 130 | node.weight = weight_best 131 | # Write the compressed data. 132 | with io.BytesIO() as buffer: 133 | # Write the header data. 134 | buffer.write(b'LZ77\x10') # The LZ type on the Wii is *always* 0x10. 135 | buffer.write(len(data).to_bytes(3, 'little')) 136 | 137 | src_pos = 0 138 | while src_pos < len(data): 139 | head = 0 140 | head_pos = buffer.tell() 141 | buffer.write(b'\x00') # Reserve a byte for the chunk head. 142 | 143 | i = 0 144 | while i < 8 and src_pos < len(data): 145 | current_node = nodes[src_pos] 146 | length = current_node.len 147 | dist = current_node.dist 148 | # This is a reference node. 149 | if _compress_node_is_ref(current_node): 150 | encoded = (((length - _LZ_MIN_LENGTH) & 0xF) << 12) | ((dist - _LZ_MIN_DISTANCE) & 0xFFF) 151 | buffer.write(encoded.to_bytes(2)) 152 | head = (head | (1 << (7 - i))) & 0xFF 153 | # This is a direct copy node. 154 | else: 155 | buffer.write(data[src_pos:src_pos + 1]) 156 | src_pos += length 157 | i += 1 158 | 159 | pos = buffer.tell() 160 | buffer.seek(head_pos) 161 | buffer.write(head.to_bytes(1)) 162 | buffer.seek(pos) 163 | 164 | buffer.seek(0) 165 | out_data = buffer.read() 166 | return out_data 167 | 168 | 169 | def _compress_lz77_greedy(data: bytes) -> bytes: 170 | # Greedy compressor that processes the file start to end and saves the first matches found. Faster than the 171 | # optimized implementation, but creates larger files. 172 | global _LZ_MAX_LENGTH, _LZ_MIN_LENGTH, _LZ_MIN_DISTANCE 173 | with io.BytesIO() as buffer: 174 | # Write the header data. 175 | buffer.write(b'LZ77\x10') # The LZ type on the Wii is *always* 0x10. 176 | buffer.write(len(data).to_bytes(3, 'little')) 177 | 178 | src_pos = 0 179 | data_list = list(data) 180 | while src_pos < len(data): 181 | head = 0 182 | head_pos = buffer.tell() 183 | buffer.write(b'\x00') # Reserve a byte for the chunk head. 184 | 185 | i = 0 186 | while i < 8 and src_pos < len(data): 187 | length, dist = _compress_search_matches_greedy(data_list, src_pos) 188 | # This is a reference node. 189 | if length >= _LZ_MIN_LENGTH: 190 | encoded = (((length - _LZ_MIN_LENGTH) & 0xF) << 12) | ((dist - _LZ_MIN_DISTANCE) & 0xFFF) 191 | buffer.write(encoded.to_bytes(2)) 192 | head = (head | (1 << (7 - i))) & 0xFF 193 | src_pos += length 194 | # This is a direct copy node. 195 | else: 196 | buffer.write(data[src_pos:src_pos + 1]) 197 | src_pos += 1 198 | i += 1 199 | 200 | pos = buffer.tell() 201 | buffer.seek(head_pos) 202 | buffer.write(head.to_bytes(1)) 203 | buffer.seek(pos) 204 | 205 | buffer.seek(0) 206 | out_data = buffer.read() 207 | return out_data 208 | 209 | 210 | def compress_lz77(data: bytes, compression_level: int = 1) -> bytes: 211 | """ 212 | Compresses data using the Wii's LZ77 compression algorithm and returns the compressed result. Supports two 213 | different levels of compression, one based around a "greedy" LZ compression algorithm and the other based around 214 | an optimized LZ compression algorithm. The greedy compressor, level 1, will produce a larger compressed file but 215 | will run noticeably faster than the optimized compressor, which is level 2, especially for larger data. 216 | 217 | Parameters 218 | ---------- 219 | data: bytes 220 | The data to compress. 221 | compression_level: int 222 | The compression level to use, either 1 and 2. Default value is 1. 223 | 224 | Returns 225 | ------- 226 | bytes 227 | The LZ77-compressed data. 228 | """ 229 | if compression_level == 1: 230 | out_data = _compress_lz77_greedy(data) 231 | elif compression_level == 2: 232 | out_data = _compress_lz77_optimized(data) 233 | else: 234 | raise ValueError(f"Invalid compression level \"{compression_level}\"!\"") 235 | return out_data 236 | 237 | 238 | def decompress_lz77(lz77_data: bytes) -> bytes: 239 | """ 240 | Decompresses LZ77-compressed data and returns the decompressed result. Supports data both with and without the 241 | magic number 'LZ77' (which may not be present if the data is embedded in something else). 242 | 243 | Parameters 244 | ---------- 245 | lz77_data: bytes 246 | The LZ77-compressed data to decompress. 247 | 248 | Returns 249 | ------- 250 | bytes 251 | The decompressed data. 252 | """ 253 | with io.BytesIO(lz77_data) as data: 254 | magic = data.read(4) 255 | # Assume if we didn't get the magic number that this data starts without it. 256 | if magic != b'LZ77': 257 | data.seek(0) 258 | # Other compression types are used by Nintendo, but only type 0x10 was used on the Wii. 259 | compression_type = int.from_bytes(data.read(1)) 260 | if compression_type != 0x10: 261 | raise ValueError("This data is using an unsupported compression type!") 262 | decompressed_size = int.from_bytes(data.read(3), byteorder='little') 263 | # Use an integer list for storing decompressed data, this is much faster than using (and appending to) a 264 | # bytes object. 265 | out_data = [0] * decompressed_size 266 | pos = 0 267 | while pos < decompressed_size: 268 | flag = int.from_bytes(data.read(1)) 269 | # Read bits in the flag from most to least significant. 270 | for x in range(7, -1, -1): 271 | # Avoids a buffer overrun if the final flag isn't fully used. 272 | if pos >= decompressed_size: 273 | break 274 | # Result of 1, this means we're copying bytes from earlier in the data. 275 | if flag & (1 << x): 276 | reference = int.from_bytes(data.read(2)) 277 | length = 3 + ((reference >> 12) & 0xF) 278 | offset = pos - (reference & 0xFFF) - 1 279 | for _ in range(length): 280 | out_data[pos] = out_data[offset] 281 | pos += 1 282 | offset += 1 283 | # Avoids a buffer overrun if the copy length would extend past the end of the file. 284 | if pos >= decompressed_size: 285 | break 286 | # Result of 0, use the next byte directly. 287 | else: 288 | out_data[pos] = int.from_bytes(data.read(1)) 289 | pos += 1 290 | out_bytes = bytes(out_data) 291 | return out_bytes 292 | -------------------------------------------------------------------------------- /src/libWiiPy/title/wad.py: -------------------------------------------------------------------------------- 1 | # "title/wad.py" from libWiiPy by NinjaCheetah & Contributors 2 | # https://github.com/NinjaCheetah/libWiiPy 3 | # 4 | # See https://wiibrew.org/wiki/WAD_files for details about the WAD format 5 | 6 | import io 7 | import binascii 8 | from ..shared import _align_value, _pad_bytes 9 | 10 | 11 | class WAD: 12 | """ 13 | A WAD object that allows for either loading and editing an existing WAD or creating a new WAD from raw data. 14 | 15 | Attributes 16 | ---------- 17 | wad_type : str 18 | The type of WAD, either ib for boot2 or Is for normal installable WADs. 19 | wad_cert_size : int 20 | The size of the WAD's certificate. 21 | wad_crl_size : int 22 | The size of the WAD's crl. 23 | wad_tik_size : int 24 | The size of the WAD's Ticket. 25 | wad_tmd_size : int 26 | The size of the WAD's TMD. 27 | wad_content_size : int 28 | The size of WAD's total content region. 29 | wad_meta_size : int 30 | The size of the WAD's meta/footer. 31 | """ 32 | def __init__(self) -> None: 33 | self.wad_hdr_size: int = 64 34 | self.wad_type: str = "Is" 35 | self.wad_version: bytes = b'\x00\x00' 36 | # === Sizes === 37 | self.wad_cert_size: int = 0 38 | self.wad_crl_size: int = 0 39 | self.wad_tik_size: int = 0 40 | self.wad_tmd_size: int = 0 41 | # This is the size of the content region, which contains all app files combined. 42 | self.wad_content_size: int = 0 43 | self.wad_meta_size: int = 0 44 | # === Data === 45 | self.wad_cert_data: bytes = b'' 46 | self.wad_crl_data: bytes = b'' 47 | self.wad_tik_data: bytes = b'' 48 | self.wad_tmd_data: bytes = b'' 49 | self.wad_content_data: bytes = b'' 50 | self.wad_meta_data: bytes = b'' 51 | 52 | def load(self, wad: bytes) -> None: 53 | """ 54 | Loads raw WAD data and sets all attributes of the WAD object. This allows for manipulating an already 55 | existing WAD file. 56 | 57 | Parameters 58 | ---------- 59 | wad : bytes 60 | The data for the WAD file to load. 61 | """ 62 | with io.BytesIO(wad) as wad_data: 63 | # Read the first 8 bytes of the file to ensure that it's a WAD. Has two possible valid values for the two 64 | # different types of WADs that might be encountered. 65 | wad_data.seek(0x0) 66 | wad_magic_bin = wad_data.read(8) 67 | wad_magic_hex = binascii.hexlify(wad_magic_bin) 68 | wad_magic = str(wad_magic_hex.decode()) 69 | if wad_magic != "0000002049730000" and wad_magic != "0000002069620000": 70 | raise TypeError("This is not a valid WAD file!") 71 | # ==================================================================================== 72 | # Get the sizes of each data region contained within the WAD. 73 | # ==================================================================================== 74 | # Header length, which will always be 64 bytes, as it is padded out if it is shorter. 75 | self.wad_hdr_size = 64 76 | # WAD type, denoting whether this WAD contains boot2 ("ib"), or anything else ("Is"). 77 | wad_data.seek(0x04) 78 | self.wad_type = str(wad_data.read(2).decode()) 79 | # WAD version, this is always 0. 80 | wad_data.seek(0x06) 81 | self.wad_version = wad_data.read(2) 82 | # WAD cert size. 83 | wad_data.seek(0x08) 84 | self.wad_cert_size = int(binascii.hexlify(wad_data.read(4)), 16) 85 | # WAD crl size. 86 | wad_data.seek(0x0c) 87 | self.wad_crl_size = int(binascii.hexlify(wad_data.read(4)), 16) 88 | # WAD ticket size. 89 | wad_data.seek(0x10) 90 | self.wad_tik_size = int(binascii.hexlify(wad_data.read(4)), 16) 91 | # WAD TMD size. 92 | wad_data.seek(0x14) 93 | self.wad_tmd_size = int(binascii.hexlify(wad_data.read(4)), 16) 94 | # WAD content size. This needs to be rounded now, because with some titles (primarily IOS?), there can be 95 | # extra bytes past the listed end of the content that is needed for decryption. 96 | wad_data.seek(0x18) 97 | self.wad_content_size = int(binascii.hexlify(wad_data.read(4)), 16) 98 | self.wad_content_size = _align_value(self.wad_content_size, 16) 99 | # Time/build stamp for the title contained in the WAD. 100 | wad_data.seek(0x1c) 101 | self.wad_meta_size = int(binascii.hexlify(wad_data.read(4)), 16) 102 | # ==================================================================================== 103 | # Calculate file offsets from sizes. Every section of the WAD is padded out to a multiple of 0x40. 104 | # ==================================================================================== 105 | wad_cert_offset = self.wad_hdr_size 106 | # crl isn't ever used, however an entry for its size exists in the header, so it's calculated just in case. 107 | wad_crl_offset = _align_value(wad_cert_offset + self.wad_cert_size) 108 | wad_tik_offset = _align_value(wad_crl_offset + self.wad_crl_size) 109 | wad_tmd_offset = _align_value(wad_tik_offset + self.wad_tik_size) 110 | wad_content_offset = _align_value(wad_tmd_offset + self.wad_tmd_size) 111 | # meta isn't guaranteed to be used, but some older SDK titles use it, and not reading it breaks things. 112 | wad_meta_offset = _align_value(wad_content_offset + self.wad_content_size) 113 | # ==================================================================================== 114 | # Load data for each WAD section based on the previously calculated offsets. 115 | # ==================================================================================== 116 | # Cert data. 117 | wad_data.seek(wad_cert_offset) 118 | self.wad_cert_data = wad_data.read(self.wad_cert_size) 119 | # Crl data. 120 | wad_data.seek(wad_crl_offset) 121 | self.wad_crl_data = wad_data.read(self.wad_crl_size) 122 | # Ticket data. 123 | wad_data.seek(wad_tik_offset) 124 | self.wad_tik_data = wad_data.read(self.wad_tik_size) 125 | # TMD data. 126 | wad_data.seek(wad_tmd_offset) 127 | self.wad_tmd_data = wad_data.read(self.wad_tmd_size) 128 | # Content data. 129 | wad_data.seek(wad_content_offset) 130 | self.wad_content_data = wad_data.read(self.wad_content_size) 131 | # Meta data. 132 | wad_data.seek(wad_meta_offset) 133 | self.wad_meta_data = wad_data.read(self.wad_meta_size) 134 | 135 | def dump(self) -> bytes: 136 | """ 137 | Dumps the WAD object into the raw WAD file. This allows for creating a WAD file from the data contained in 138 | the WAD object. 139 | 140 | Returns 141 | ------- 142 | bytes 143 | The full WAD file as bytes. 144 | """ 145 | wad_data = b'' 146 | # Lead-in data. 147 | wad_data += b'\x00\x00\x00\x20' 148 | # WAD type. 149 | wad_data += str.encode(self.wad_type) 150 | # WAD version. 151 | wad_data += self.wad_version 152 | # WAD cert size. 153 | wad_data += int.to_bytes(self.wad_cert_size, 4) 154 | # WAD crl size. 155 | wad_data += int.to_bytes(self.wad_crl_size, 4) 156 | # WAD ticket size. 157 | wad_data += int.to_bytes(self.wad_tik_size, 4) 158 | # WAD TMD size. 159 | wad_data += int.to_bytes(self.wad_tmd_size, 4) 160 | # WAD content size. 161 | wad_data += int.to_bytes(self.wad_content_size, 4) 162 | # WAD meta size. 163 | wad_data += int.to_bytes(self.wad_meta_size, 4) 164 | wad_data = _pad_bytes(wad_data) 165 | # Retrieve the cert data and write it out. 166 | wad_data += self.get_cert_data() 167 | wad_data = _pad_bytes(wad_data) 168 | # Retrieve the crl data and write it out. 169 | wad_data += self.get_crl_data() 170 | wad_data = _pad_bytes(wad_data) 171 | # Retrieve the ticket data and write it out. 172 | wad_data += self.get_ticket_data() 173 | wad_data = _pad_bytes(wad_data) 174 | # Retrieve the TMD data and write it out. 175 | wad_data += self.get_tmd_data() 176 | wad_data = _pad_bytes(wad_data) 177 | # Retrieve the content data and write it out. 178 | wad_data += self.get_content_data() 179 | wad_data = _pad_bytes(wad_data) 180 | # Retrieve the meta/footer data and write it out. 181 | wad_data += self.get_meta_data() 182 | wad_data = _pad_bytes(wad_data) 183 | return wad_data 184 | 185 | def get_wad_type(self) -> str: 186 | """ 187 | Gets the type of the WAD. 188 | 189 | Returns 190 | ------- 191 | str 192 | The type of the WAD. This is 'Is', unless the WAD contains boot2, where it is 'ib'. 193 | """ 194 | return self.wad_type 195 | 196 | def get_cert_data(self) -> bytes: 197 | """ 198 | Gets the certificate data from the WAD. 199 | 200 | Returns 201 | ------- 202 | bytes 203 | The certificate data. 204 | """ 205 | return self.wad_cert_data 206 | 207 | def get_crl_data(self) -> bytes: 208 | """ 209 | Gets the crl data from the WAD, if it exists. 210 | 211 | Returns 212 | ------- 213 | bytes 214 | The crl data. 215 | """ 216 | return self.wad_crl_data 217 | 218 | def get_ticket_data(self) -> bytes: 219 | """ 220 | Gets the ticket data from the WAD. 221 | 222 | Returns 223 | ------- 224 | bytes 225 | The ticket data. 226 | """ 227 | return self.wad_tik_data 228 | 229 | def get_tmd_data(self) -> bytes: 230 | """ 231 | Returns the TMD data from the WAD. 232 | 233 | Returns 234 | ------- 235 | bytes 236 | The TMD data. 237 | """ 238 | return self.wad_tmd_data 239 | 240 | def get_content_data(self) -> bytes: 241 | """ 242 | Gets the content of the WAD. 243 | 244 | Returns 245 | ------- 246 | bytes 247 | The content data. 248 | """ 249 | return self.wad_content_data 250 | 251 | def get_meta_data(self) -> bytes: 252 | """ 253 | Gets the meta region of the WAD, which is typically unused. 254 | 255 | Returns 256 | ------- 257 | bytes 258 | The meta region. 259 | """ 260 | return self.wad_meta_data 261 | 262 | def set_cert_data(self, cert_data) -> None: 263 | """ 264 | Sets the certificate data of the WAD. Also calculates the new size. 265 | 266 | Parameters 267 | ---------- 268 | cert_data : bytes 269 | The new certificate data. 270 | """ 271 | self.wad_cert_data = cert_data 272 | # Calculate the size of the new cert data. 273 | self.wad_cert_size = len(cert_data) 274 | 275 | def set_crl_data(self, crl_data) -> None: 276 | """ 277 | Sets the crl data of the WAD. Also calculates the new size. 278 | 279 | Parameters 280 | ---------- 281 | crl_data : bytes 282 | The new crl data. 283 | """ 284 | self.wad_crl_data = crl_data 285 | # Calculate the size of the new crl data. 286 | self.wad_crl_size = len(crl_data) 287 | 288 | def set_tmd_data(self, tmd_data) -> None: 289 | """ 290 | Sets the TMD data of the WAD. Also calculates the new size. 291 | 292 | Parameters 293 | ---------- 294 | tmd_data : bytes 295 | The new TMD data. 296 | """ 297 | self.wad_tmd_data = tmd_data 298 | # Calculate the size of the new TMD data. 299 | self.wad_tmd_size = len(tmd_data) 300 | 301 | def set_ticket_data(self, tik_data) -> None: 302 | """ 303 | Sets the Ticket data of the WAD. Also calculates the new size. 304 | 305 | Parameters 306 | ---------- 307 | tik_data : bytes 308 | The new TMD data. 309 | """ 310 | self.wad_tik_data = tik_data 311 | # Calculate the size of the new Ticket data. 312 | self.wad_tik_size = len(tik_data) 313 | 314 | def set_content_data(self, content_data, size: int | None = None) -> None: 315 | """ 316 | Sets the content data of the WAD. Also calculates the new size. 317 | 318 | Parameters 319 | ---------- 320 | content_data : bytes 321 | The new content data. 322 | size : int, option 323 | The size of the new content data. 324 | """ 325 | self.wad_content_data = content_data 326 | # Calculate the size of the new content data, if one wasn't supplied. 327 | if size is None: 328 | self.wad_content_size = len(content_data) 329 | else: 330 | self.wad_content_size = size 331 | 332 | def set_meta_data(self, meta_data) -> None: 333 | """ 334 | Sets the meta data of the WAD. Also calculates the new size. 335 | 336 | Parameters 337 | ---------- 338 | meta_data : bytes 339 | The new meta data. 340 | """ 341 | self.wad_meta_data = meta_data 342 | # Calculate the size of the new meta data. 343 | self.wad_meta_size = len(meta_data) 344 | -------------------------------------------------------------------------------- /src/libWiiPy/nand/emunand.py: -------------------------------------------------------------------------------- 1 | # "nand/emunand.py" from libWiiPy by NinjaCheetah & Contributors 2 | # https://github.com/NinjaCheetah/libWiiPy 3 | # 4 | # Code for handling setting up and modifying a Wii EmuNAND. 5 | 6 | import os 7 | import pathlib 8 | import shutil 9 | from dataclasses import dataclass as _dataclass 10 | from typing import Callable, List 11 | 12 | from ..title.ticket import Ticket 13 | from ..title.title import Title 14 | from ..title.tmd import TMD 15 | from ..title.content import SharedContentMap as _SharedContentMap 16 | from .sys import UidSys as _UidSys 17 | 18 | 19 | class EmuNAND: 20 | """ 21 | An EmuNAND object that allows for creating and modifying Wii EmuNANDs. Requires the path to the root of the 22 | EmuNAND, and can optionally take in a callback function to send logs to. 23 | 24 | Parameters 25 | ---------- 26 | emunand_root : str, pathlib.Path 27 | The path to the EmuNAND root directory. 28 | callback : function 29 | A callback function to send EmuNAND logs to. 30 | 31 | Attributes 32 | ---------- 33 | emunand_root : pathlib.Path 34 | The path to the EmuNAND root directory. 35 | """ 36 | def __init__(self, emunand_root: str | pathlib.Path, callback: Callable | None = None): 37 | self.emunand_root = pathlib.Path(emunand_root) 38 | self.log = callback if callback is not None else lambda x: None 39 | 40 | self.import_dir = self.emunand_root.joinpath("import") 41 | self.meta_dir = self.emunand_root.joinpath("meta") 42 | self.shared1_dir = self.emunand_root.joinpath("shared1") 43 | self.shared2_dir = self.emunand_root.joinpath("shared2") 44 | self.sys_dir = self.emunand_root.joinpath("sys") 45 | self.ticket_dir = self.emunand_root.joinpath("ticket") 46 | self.title_dir = self.emunand_root.joinpath("title") 47 | self.tmp_dir = self.emunand_root.joinpath("tmp") 48 | self.wfs_dir = self.emunand_root.joinpath("wfs") 49 | 50 | self.import_dir.mkdir(exist_ok=True) 51 | self.meta_dir.mkdir(exist_ok=True) 52 | self.shared1_dir.mkdir(exist_ok=True) 53 | self.shared2_dir.mkdir(exist_ok=True) 54 | self.sys_dir.mkdir(exist_ok=True) 55 | self.ticket_dir.mkdir(exist_ok=True) 56 | self.title_dir.mkdir(exist_ok=True) 57 | self.tmp_dir.mkdir(exist_ok=True) 58 | self.wfs_dir.mkdir(exist_ok=True) 59 | 60 | def install_title(self, title: Title, skip_hash=False) -> None: 61 | """ 62 | Install the provided Title object to the EmuNAND. This mimics a real WAD installation done by ES. 63 | 64 | This will create some system files required if they do not exist, but note that this alone is not enough for 65 | a working EmuNAND, other than for Dolphin which can fill in the gaps. 66 | 67 | Parameters 68 | ---------- 69 | title : libWiiPy.title.Title 70 | The loaded Title object to install. 71 | skip_hash : bool, optional 72 | Skip the hash check and install the title regardless of its hashes. Defaults to false. 73 | """ 74 | self.log(f"[PROGRESS] Starting install of title with Title ID {title.tmd.title_id}...") 75 | # Save the upper and lower portions of the Title ID, because these are used as target install directories. 76 | tid_upper = title.tmd.title_id[:8] 77 | tid_lower = title.tmd.title_id[8:] 78 | 79 | # Tickets are installed as .tik in /ticket// 80 | ticket_dir = self.ticket_dir.joinpath(tid_upper) 81 | self.log(f"[PROGRESS] Installing ticket to \"{ticket_dir}\"...") 82 | ticket_dir.mkdir(exist_ok=True) 83 | ticket_dir.joinpath(f"{tid_lower}.tik").write_bytes(title.ticket.dump()) 84 | 85 | # The TMD and normal contents are installed to /title///content/, with the tmd being named 86 | # title.tmd and the contents being named .app. 87 | title_dir = self.title_dir.joinpath(tid_upper) 88 | title_dir.mkdir(exist_ok=True) 89 | title_dir = title_dir.joinpath(tid_lower) 90 | title_dir.mkdir(exist_ok=True) 91 | content_dir = title_dir.joinpath("content") 92 | self.log(f"[PROGRESS] Installing TMD to \"{content_dir}\"...") 93 | if content_dir.exists(): 94 | shutil.rmtree(content_dir) # Clear the content directory so old contents aren't left behind. 95 | content_dir.mkdir(exist_ok=True) 96 | content_dir.joinpath("title.tmd").write_bytes(title.tmd.dump()) 97 | self.log(f"[PROGRESS] Installing content to \"{content_dir}\"...") 98 | if skip_hash: 99 | self.log("[WARN] Not checking content hashes! Content validity will not be verified.") 100 | for content_file in range(0, title.tmd.num_contents): 101 | if title.tmd.content_records[content_file].content_type == 1: 102 | content_file_name = f"{title.tmd.content_records[content_file].content_id:08X}".lower() 103 | self.log(f"[PROGRESS] Installing content \"{content_file_name}.app\" to \"{content_dir}\"... ") 104 | content_dir.joinpath(f"{content_file_name}.app").write_bytes( 105 | title.get_content_by_index(content_file, skip_hash=skip_hash)) 106 | title_dir.joinpath("data").mkdir(exist_ok=True) # Empty directory used for save data for the title. 107 | 108 | # Shared contents need to be installed to /shared1/, with incremental names determined by /shared1/content.map. 109 | content_map_path = self.shared1_dir.joinpath("content.map") 110 | self.log(f"[PROGRESS] Installing shared content to \"{self.shared1_dir}\"...") 111 | content_map = _SharedContentMap() 112 | existing_hashes = [] 113 | if content_map_path.exists(): 114 | content_map.load(content_map_path.read_bytes()) 115 | for record in content_map.shared_records: 116 | existing_hashes.append(record.content_hash) 117 | for content_file in range(0, title.tmd.num_contents): 118 | if title.tmd.content_records[content_file].content_type == 32769: 119 | if title.tmd.content_records[content_file].content_hash not in existing_hashes: 120 | self.log(f"[PROGRESS] Adding shared content hash to content.map...") 121 | content_file_name = content_map.add_content(title.tmd.content_records[content_file].content_hash) 122 | self.log(f"[PROGRESS] Installing shared content \"{content_file_name}.app\" to " 123 | f"\"{self.shared1_dir}\"...") 124 | self.shared1_dir.joinpath(f"{content_file_name}.app").write_bytes( 125 | title.get_content_by_index(content_file, skip_hash=skip_hash)) 126 | self.shared1_dir.joinpath("content.map").write_bytes(content_map.dump()) 127 | 128 | # The "footer" or meta file is installed as title.met in /meta///. Only write this if meta 129 | # is not nothing. 130 | meta_data = title.wad.get_meta_data() 131 | if meta_data != b'': 132 | meta_dir = self.meta_dir.joinpath(tid_upper) 133 | meta_dir.mkdir(exist_ok=True) 134 | meta_dir = meta_dir.joinpath(tid_lower) 135 | self.log(f"[PROGRESS] Installing meta data to \"{meta_dir}\"...") 136 | meta_dir.mkdir(exist_ok=True) 137 | meta_dir.joinpath("title.met").write_bytes(title.wad.get_meta_data()) 138 | 139 | # Ensure we have a uid.sys file created. 140 | uid_sys_path = self.sys_dir.joinpath("uid.sys") 141 | uid_sys = _UidSys() 142 | if not uid_sys_path.exists(): 143 | self.log("[WARN] uid.sys does not exist! Creating it with the default entry.") 144 | uid_sys.create() 145 | else: 146 | uid_sys.load(uid_sys_path.read_bytes()) 147 | self.log("[PROGRESS] Adding title to uid.sys and assigning a new UID...") 148 | uid_sys.add(title.tmd.title_id) 149 | uid_sys_path.write_bytes(uid_sys.dump()) 150 | 151 | # Check for a cert.sys and initialize it using the certs in the WAD if it doesn't exist. 152 | cert_sys_path = self.sys_dir.joinpath("cert.sys") 153 | if not cert_sys_path.exists(): 154 | self.log("[WARN] cert.sys does not exist! Creating it using certs from the installed title...") 155 | cert_sys_data = b'' 156 | cert_sys_data += title.cert_chain.ticket_cert.dump() 157 | cert_sys_data += title.cert_chain.ca_cert.dump() 158 | cert_sys_data += title.cert_chain.tmd_cert.dump() 159 | cert_sys_path.write_bytes(cert_sys_data) 160 | 161 | self.log("[PROGRESS] Completed title installation.") 162 | 163 | def uninstall_title(self, tid: str) -> None: 164 | """ 165 | Uninstall the Title with the specified Title ID from the EmuNAND. This will leave shared contents unmodified. 166 | 167 | Parameters 168 | ---------- 169 | tid : str 170 | The Title ID of the Title to uninstall. 171 | """ 172 | # Save the upper and lower portions of the Title ID, because these are used as target install directories. 173 | tid_upper = tid[:8] 174 | tid_lower = tid[8:] 175 | 176 | if not self.title_dir.joinpath(tid_upper).joinpath(tid_lower).exists(): 177 | raise ValueError(f"Title with Title ID {tid} does not appear to be installed!") 178 | 179 | # Begin by removing the Ticket, which is installed to /ticket//.tik 180 | if self.ticket_dir.joinpath(tid_upper).joinpath(tid_lower + ".tik").exists(): 181 | os.remove(self.ticket_dir.joinpath(tid_upper).joinpath(tid_lower + ".tik")) 182 | 183 | # The TMD and contents are stored in /title///. Remove the TMD and all contents, but don't 184 | # delete the entire directory if anything exists in data. 185 | title_dir = self.title_dir.joinpath(tid_upper).joinpath(tid_lower) 186 | if not title_dir.joinpath("data").exists(): 187 | shutil.rmtree(title_dir) 188 | elif title_dir.joinpath("data").exists() and not os.listdir(title_dir.joinpath("data")): 189 | shutil.rmtree(title_dir) 190 | else: 191 | # There are files in data, so we only want to delete the content directory. 192 | shutil.rmtree(title_dir.joinpath("content")) 193 | 194 | # On the off chance this title has a meta entry, delete that too. 195 | if self.meta_dir.joinpath(tid_upper).joinpath(tid_lower).joinpath("title.met").exists(): 196 | shutil.rmtree(self.meta_dir.joinpath(tid_upper).joinpath(tid_lower)) 197 | 198 | @_dataclass 199 | class InstalledTitles: 200 | """ 201 | An InstalledTitles object that is used to track a title type and any titles that belong to that type that are 202 | installed to an EmuNAND. 203 | 204 | :ivar type: The type (Title ID high) of the installed titles. 205 | :ivar titles: The Title ID low of each installed title. 206 | """ 207 | type: str 208 | titles: List[str] 209 | 210 | def get_installed_titles(self) -> List[InstalledTitles]: 211 | """ 212 | Scans for installed titles and returns a list of InstalledTitles objects, which each contain a title type 213 | (Title ID high) and a list of Title ID lows that are installed under it. 214 | 215 | Returns 216 | ------- 217 | List[InstalledTitles] 218 | The titles installed to the EmuNAND. 219 | """ 220 | # Scan for TID highs present. 221 | tid_highs = [d for d in self.title_dir.iterdir() if d.is_dir()] 222 | # Iterate through each one, verify that every TID low directory contains a TMD, and then add it to the list. 223 | installed_titles = [] 224 | for high in tid_highs: 225 | tid_lows = [d for d in high.iterdir() if d.is_dir()] 226 | valid_lows = [] 227 | for low in tid_lows: 228 | if low.joinpath("content", "title.tmd").exists(): 229 | valid_lows.append(low.name.upper()) 230 | installed_titles.append(self.InstalledTitles(high.name.upper(), valid_lows)) 231 | return installed_titles 232 | 233 | def get_title_tmd(self, tid: str) -> TMD: 234 | """ 235 | Gets the TMD for a title installed to the EmuNAND, and returns it as a TMD objects. Returns an error if the 236 | TMD for the specified Title ID does not exist. 237 | 238 | Parameters 239 | ---------- 240 | tid : str 241 | The Title ID of the Title to get the TMD for. 242 | 243 | Returns 244 | ------- 245 | TMD 246 | The TMD for the Title. 247 | """ 248 | # Validate the TID, then build a path to the TMD file to verify that it exists. 249 | if len(tid) != 16: 250 | raise ValueError(f"Title ID \"{tid}\" is not a valid!") 251 | tid_high = tid[:8].lower() 252 | tid_low = tid[8:].lower() 253 | tmd_path = self.title_dir.joinpath(tid_high, tid_low, "content", "title.tmd") 254 | if not tmd_path.exists(): 255 | raise FileNotFoundError(f"Title with Title ID {tid} does not appear to be installed!") 256 | tmd = TMD() 257 | tmd.load(tmd_path.read_bytes()) 258 | return tmd 259 | 260 | def get_title_ticket(self, tid: str) -> Ticket: 261 | """ 262 | Gets the Ticket for a title installed to the EmuNAND, and returns it as a Ticket object. Returns an error if 263 | the Ticket for the specified Title ID does not exist. 264 | 265 | Parameters 266 | ---------- 267 | tid : str 268 | The Title ID of the Title to get the Ticket for. 269 | 270 | Returns 271 | ------- 272 | Ticket 273 | The Ticket for the Title. 274 | """ 275 | # Validate the TID, then build a path to the Ticket files to verify that it exists. 276 | if len(tid) != 16: 277 | raise ValueError(f"Title ID \"{tid}\" is not a valid!") 278 | tid_high = tid[:8].lower() 279 | tid_low = tid[8:].lower() 280 | ticket_path = self.ticket_dir.joinpath(tid_high, f"{tid_low}.tik") 281 | if not ticket_path.exists(): 282 | raise FileNotFoundError(f"No Ticket exists for the title with Title ID {tid}!") 283 | ticket = Ticket() 284 | ticket.load(ticket_path.read_bytes()) 285 | return ticket 286 | -------------------------------------------------------------------------------- /src/libWiiPy/title/cert.py: -------------------------------------------------------------------------------- 1 | # "title/cert.py" from libWiiPy by NinjaCheetah & Contributors 2 | # https://github.com/NinjaCheetah/libWiiPy 3 | # 4 | # See https://wiibrew.org/wiki/Certificate_chain for details about the Wii's certificate chain 5 | 6 | import io 7 | from enum import IntEnum as _IntEnum 8 | 9 | from Crypto.Hash import SHA1 10 | from Crypto.PublicKey import RSA 11 | from Crypto.Signature import pkcs1_15 12 | 13 | from ..shared import _align_value, _pad_bytes 14 | from .ticket import Ticket 15 | from .tmd import TMD 16 | 17 | 18 | class CertificateType(_IntEnum): 19 | """ 20 | The type of a certificate. 21 | """ 22 | RSA_4096 = 0x00010000 23 | RSA_2048 = 0x00010001 24 | ECC = 0x00010002 25 | 26 | 27 | class CertificateSignatureLength(_IntEnum): 28 | """ 29 | The length of a certificate's signature. 30 | """ 31 | RSA_4096 = 0x200 32 | RSA_2048 = 0x100 33 | ECC = 0x3C 34 | 35 | 36 | class CertificateKeyType(_IntEnum): 37 | """ 38 | The type of key contained in a certificate. 39 | """ 40 | RSA_4096 = 0x00000000 41 | RSA_2048 = 0x00000001 42 | ECC = 0x00000002 43 | 44 | 45 | class CertificateKeyLength(_IntEnum): 46 | """ 47 | The length of the key contained in a certificate. 48 | """ 49 | RSA_4096 = 0x200 50 | RSA_2048 = 0x100 51 | ECC = 0x3C 52 | 53 | 54 | class Certificate: 55 | """ 56 | A Certificate object used to parse a certificate used for the Wii's content verification. 57 | 58 | Attributes 59 | ---------- 60 | type: CertificateType 61 | The type of the certificate, either RSA-2048, RSA-4096, or ECC. 62 | signature: bytes 63 | The signature data of the certificate. 64 | issuer: str 65 | The certificate that issued this certificate. 66 | pub_key_type: CertificateKeyType 67 | The type of public key contained in the certificate, either RSA-2048, RSA-4096, or ECC. 68 | child_name: str 69 | The name of this certificate. 70 | pub_key_id: int 71 | The ID of this certificate's public key. 72 | pub_key_modulus: int 73 | The modulus of this certificate's public key. Combined with the exponent to get the full key. 74 | pub_key_exponent: int 75 | The exponent of this certificate's public key. Combined with the modulus to get the full key. 76 | """ 77 | def __init__(self) -> None: 78 | self.type: CertificateType = CertificateType.RSA_4096 79 | self.signature: bytes = b'' 80 | self.issuer: str = "" 81 | self.pub_key_type: CertificateKeyType = CertificateKeyType.RSA_4096 82 | self.child_name: str = "" 83 | self.pub_key_id: int = 0 84 | self.pub_key_modulus: int = 0 85 | self.pub_key_exponent: int = 0 86 | 87 | def load(self, cert: bytes) -> None: 88 | """ 89 | Loads certificate data into the Certificate object, allowing you to parse the certificate. 90 | 91 | Parameters 92 | ---------- 93 | cert: bytes 94 | The data for the certificate to load. 95 | """ 96 | with io.BytesIO(cert) as cert_data: 97 | # Read the first 4 bytes of the cert to get the certificate's type. 98 | try: 99 | self.type = CertificateType.from_bytes(cert_data.read(0x4)) 100 | except ValueError: 101 | raise ValueError("Invalid Certificate Type!") 102 | cert_length = CertificateSignatureLength[self.type.name] 103 | self.signature = cert_data.read(cert_length.value) 104 | cert_data.seek(0x40 + cert_length.value) 105 | self.issuer = str(cert_data.read(0x40).replace(b'\x00', b'').decode()) 106 | try: 107 | cert_data.seek(0x80 + cert_length.value) 108 | self.pub_key_type = CertificateKeyType.from_bytes(cert_data.read(0x4)) 109 | except ValueError: 110 | raise ValueError("Invalid Certificate Key type!") 111 | cert_data.seek(0x84 + cert_length.value) 112 | self.child_name = str(cert_data.read(0x40).replace(b'\x00', b'').decode()) 113 | cert_data.seek(0xC4 + cert_length.value) 114 | self.pub_key_id = int.from_bytes(cert_data.read(0x4)) 115 | key_length = CertificateKeyLength[self.pub_key_type.name] 116 | cert_data.seek(0xC8 + cert_length.value) 117 | self.pub_key_modulus = int.from_bytes(cert_data.read(key_length.value)) 118 | if self.pub_key_type == CertificateKeyType.RSA_4096 or self.pub_key_type == CertificateKeyType.RSA_2048: 119 | self.pub_key_exponent = int.from_bytes(cert_data.read(0x4)) 120 | 121 | def dump(self) -> bytes: 122 | """ 123 | Dump the certificate object back into bytes. 124 | 125 | Returns 126 | ------- 127 | bytes: 128 | The certificate file as bytes. 129 | """ 130 | cert_data = b'' 131 | cert_data += int.to_bytes(self.type.value, 4) 132 | cert_data += self.signature 133 | cert_data = _pad_bytes(cert_data) 134 | # Pad out the issuer name with null bytes. 135 | issuer = self.issuer.encode() 136 | while len(issuer) < 0x40: 137 | issuer += b'\x00' 138 | cert_data += issuer 139 | cert_data += int.to_bytes(self.pub_key_type.value, 4) 140 | # Pad out the child cert name with null bytes 141 | child_name = self.child_name.encode() 142 | while len(child_name) < 0x40: 143 | child_name += b'\x00' 144 | cert_data += child_name 145 | cert_data += int.to_bytes(self.pub_key_id, 4) 146 | cert_data += int.to_bytes(self.pub_key_modulus, CertificateKeyLength[self.pub_key_type.name]) 147 | if self.pub_key_type == CertificateKeyType.RSA_4096 or self.pub_key_type == CertificateKeyType.RSA_2048: 148 | cert_data += int.to_bytes(self.pub_key_exponent, 4) 149 | # Pad out the certificate data to a multiple of 64. 150 | cert_data = _pad_bytes(cert_data) 151 | return cert_data 152 | 153 | 154 | class CertificateChain: 155 | """ 156 | A CertificateChain object used to parse the chain of certificates stored in a WAD that are used for the Wii's 157 | content verification. The certificate chain is the format that the certificates are stored in as part of every WAD. 158 | 159 | Attributes 160 | ---------- 161 | ca_cert: Certificate 162 | The CA certificate from the chain. 163 | tmd_cert: Certificate 164 | The CP (TMD) certificate from the chain. 165 | ticket_cert: Certificate 166 | The XS (Ticket) certificate from the chain. 167 | """ 168 | def __init__(self) -> None: 169 | self.ca_cert: Certificate = Certificate() 170 | self.tmd_cert: Certificate = Certificate() 171 | self.ticket_cert: Certificate = Certificate() 172 | 173 | def load(self, cert_chain: bytes) -> None: 174 | """ 175 | Loads certificate chain data into the CertificateChain object, allowing you to parse the individual 176 | certificates stored in the chain. 177 | 178 | Parameters 179 | ---------- 180 | cert_chain: bytes 181 | The data for the certificate chain to load. 182 | """ 183 | with (io.BytesIO(cert_chain) as cert_chain_data): 184 | # Read the two fields that denote different length sections of the certificate, so that we know how long 185 | # this certificate is in total. 186 | offset = 0x0 187 | for _ in range(3): 188 | cert_chain_data.seek(offset) 189 | cert_type = CertificateType.from_bytes(cert_chain_data.read(0x4)) 190 | cert_chain_data.seek(offset + 0x80 + CertificateSignatureLength[cert_type.name].value) 191 | key_type = CertificateKeyType.from_bytes(cert_chain_data.read(0x4)) 192 | cert_size = _align_value(0xC8 + CertificateSignatureLength[cert_type.name].value + 193 | CertificateKeyLength[key_type.name].value) 194 | cert_chain_data.seek(offset + 0x0) 195 | cert = Certificate() 196 | cert.load(cert_chain_data.read(cert_size)) 197 | if cert.issuer == "Root": 198 | self.ca_cert = cert 199 | elif cert.issuer.find("Root-CA") != -1: 200 | if cert.child_name.find("CP") != -1: 201 | self.tmd_cert = cert 202 | elif cert.child_name.find("XS") != -1: 203 | self.ticket_cert = cert 204 | else: 205 | raise ValueError("Unknown certificate in chain!") 206 | else: 207 | raise ValueError("Unknown certificate in chain!") 208 | offset += cert_size 209 | 210 | def dump(self) -> bytes: 211 | """ 212 | Dumps the full certificate chain back into bytes. This chain will always be formatted with the CA cert first, 213 | followed by the CP (TMD) cert, then finally the XS (Ticket) cert. 214 | 215 | Returns 216 | ------- 217 | bytes 218 | The full certificate chain as bytes. 219 | """ 220 | cert_chain_data = b'' 221 | cert_chain_data += self.ca_cert.dump() 222 | cert_chain_data += self.tmd_cert.dump() 223 | cert_chain_data += self.ticket_cert.dump() 224 | return cert_chain_data 225 | 226 | 227 | def verify_ca_cert(ca_cert: Certificate) -> bool: 228 | """ 229 | Verify a Wii CA certificate using the root public key. The retail or development root key will be automatically 230 | selected based off of the name of the CA certificate provided. 231 | 232 | Parameters 233 | ---------- 234 | ca_cert: Certificate 235 | The CA certificate to verify. 236 | 237 | Returns 238 | ------- 239 | bool 240 | Whether the certificate is valid or not. 241 | """ 242 | if ca_cert.issuer != "Root" or ca_cert.child_name.find("CA") == -1: 243 | raise ValueError("The provided certificate is not a CA certificate!") 244 | if ca_cert.child_name == "CA00000001": 245 | root_key_modulus = \ 246 | (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~' 247 | 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' 248 | b'\xfb \x86\x9d\x7f0\x16\xf6\xbee\xd3\x83\xa1m\xb32\x1b\x955\x18\x90\xb1p\x02\x93~\xe1\x93\xf5~\x99\xa2GN' 249 | 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' 250 | 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' 251 | 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' 252 | b'\xf5\xad(>\xca\x0bz\xf3TU\xe0=\xa7\xb6\x83&\xf3\xec\x83J\xf3\x14\x04\x8a\xc6\xdf \xd2\x85\x08g<\xabb\xa2' 253 | 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' 254 | b'\xc8\xa0Gi\x00\xc5\xe6\x88\xe8\x0c\xce=Q)\r\xaajY{\x08\x1f\x9d63' 255 | b'\xa3Fz5a\t\xac\xa7\xdd}./\xb2\xc1\xae\xb8\xe2\x0fH\x92\xd8\xb9\xf8\xb4oN<\x11\xf4\xf4}\x8bu}\xfe\xfe\xa3' 256 | b'\x89\x9c3Y\\^\xfd\xeb\xcb\xab\xe8A>:\x9a\x80\xa7\xd4\xa5\x0c\xec;s\x84\xde\x88n\x82\xd2\xebMNB\xb5\xf2\xb1I\xa8\x1e' 260 | b'\xa7\xceqD\xdc)\x94\xcf\xc4N\x1f\x91\xcb\xd4\x95') 261 | elif ca_cert.child_name == "CA00000002": 262 | root_key_modulus = \ 263 | (b'\x00\xd0\x1f\xe1\x00\xd45V\xb2KV\xda\xe9q\xb5\xa5\xd3\x84\xb90\x03\xbe\x1b\xbf(\xa20[\x06\x06EF}[\x02Q' 264 | b'\xd2V\x1a\'O\x9e\x9f\x9c\xecdaP\xab=*\xe36hf\xac\xa4\xba\xe8\x1a\xe3\xd7\x9a\xa6\xb0J\x8b\xcb\xa7\xe6' 265 | 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' 266 | b'\xe4\x03"\x08\x81K\x0c\xee\xa1\xa1}\xf79i_a~\xf65(\xdb\x94\x967\xa0V\x03\x7f{2A8\x95\xc0\xa8\xf1\x98.' 267 | 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' 268 | 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' 269 | b'\x98\x06\xa1\x1c\x9c\xc10\xb2G\xa3<\x8dG\xdeg\xf2\x9eUw\xb1\x1cCI=[\xbav4\xa7\xe4\xe7\x151\xb7\xdfY\x81' 270 | b'\xfe$\xa1\x14UL\xbd\x8f\x00\\\xe1\xdb5\x08\\\xcf\xc7x\x06\xb6\xde%@h\xa2l\xb5I-E\x80C\x8f\xe1\xe5\xa9' 271 | b'\xedu\xc5\xedE\x1d\xcex\x949\xcc\xc3\xba(\xa21*\x1b\x87\x19\xef\x0fs\xb7\x13\x95\x0c\x02Y\x1atb\xa6\x07' 272 | 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' 273 | 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' 274 | 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' 275 | 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' 276 | b'\xb7Q\xafPs)\xc4\xa3\x93\x9e\xa6\xdd\x9cP\xa0\xe78k\x01EykA\xafa\xf7\x85U\x94O;\xc2-\xc3\xbd\r\x00\xf8y' 277 | b'\x8aB\xb1\xaa\xa0\x83 e\x9a\xc79Z\xb4\xf3)') 278 | else: 279 | raise ValueError("The provided CA certificate is not valid!") 280 | root_key_exponent = 0x00010001 281 | cert_hash = SHA1.new(ca_cert.dump()[576:]) 282 | public_key = RSA.construct((int.from_bytes(root_key_modulus), root_key_exponent)) 283 | try: 284 | pkcs1_15.new(public_key).verify(cert_hash, ca_cert.signature) 285 | return True 286 | except ValueError: 287 | return False 288 | 289 | 290 | def verify_cert_sig(ca_cert: Certificate, target_cert: Certificate) -> bool: 291 | """ 292 | Verify a TMD or Ticket certificate using a CA certificate. 293 | 294 | Parameters 295 | ---------- 296 | ca_cert: Certificate 297 | The CA certificate to use for verification. 298 | target_cert: Certificate 299 | The target certificate to verify. 300 | 301 | Returns 302 | ------- 303 | bool 304 | Whether the certificate's signature is valid or not. 305 | """ 306 | if ca_cert.issuer != "Root" or ca_cert.child_name.find("CA") == -1: 307 | raise ValueError("The provided certificate is not a CA certificate!") 308 | # The issuer of the TMD/Ticket certs is Root-CA0000000X, so prepend "Root-" to the CA cert child name. If these 309 | # don't match, then there's probably a mismatch between retail and development certs. 310 | if f"Root-{ca_cert.child_name}" != target_cert.issuer: 311 | raise ValueError("The certificate you are trying to verify does not match the provided CA certificate!") 312 | cert_hash = SHA1.new(target_cert.dump()[320:]) 313 | public_key = RSA.construct((ca_cert.pub_key_modulus, ca_cert.pub_key_exponent)) 314 | try: 315 | pkcs1_15.new(public_key).verify(cert_hash, target_cert.signature) 316 | return True 317 | except ValueError: 318 | return False 319 | 320 | 321 | def verify_tmd_sig(tmd_cert: Certificate, tmd: TMD) -> bool: 322 | """ 323 | Verify the signature of a TMD file using a TMD certificate. 324 | 325 | Parameters 326 | ---------- 327 | tmd_cert: Certificate 328 | The TMD certificate to use for verification. 329 | tmd: TMD 330 | The TMD to verify. 331 | 332 | Returns 333 | ------- 334 | bool 335 | Whether the TMD's signature is valid or not. 336 | """ 337 | if tmd_cert.issuer.find("Root-CA") == -1 or tmd_cert.child_name.find("CP") == -1: 338 | raise ValueError("The provided TMD certificate is not valid!") 339 | if f"{tmd_cert.issuer}-{tmd_cert.child_name}" != tmd.signature_issuer: 340 | raise ValueError("The signature you are trying to verify was not created with the provided TMD certificate!") 341 | tmd_hash = SHA1.new(tmd.dump()[320:]) 342 | public_key = RSA.construct((tmd_cert.pub_key_modulus, tmd_cert.pub_key_exponent)) 343 | try: 344 | pkcs1_15.new(public_key).verify(tmd_hash, tmd.signature) 345 | return True 346 | except ValueError: 347 | return False 348 | 349 | 350 | def verify_ticket_sig(ticket_cert: Certificate, ticket: Ticket) -> bool: 351 | """ 352 | Verify the signature of a Ticket file using a Ticket certificate. 353 | 354 | Parameters 355 | ---------- 356 | ticket_cert: Certificate 357 | The Ticket certificate to use for verification. 358 | ticket: Ticket 359 | The Ticket to verify. 360 | 361 | Returns 362 | ------- 363 | bool 364 | Whether the Ticket's signature is valid or not. 365 | """ 366 | if ticket_cert.issuer.find("Root-CA") == -1 or ticket_cert.child_name.find("XS") == -1: 367 | raise ValueError("The provided Ticket certificate is not valid!") 368 | if f"{ticket_cert.issuer}-{ticket_cert.child_name}" != ticket.signature_issuer: 369 | raise ValueError("The signature you are trying to verify was not created with the provided Ticket certificate!") 370 | ticket_hash = SHA1.new(ticket.dump()[320:]) 371 | public_key = RSA.construct((ticket_cert.pub_key_modulus, ticket_cert.pub_key_exponent)) 372 | try: 373 | pkcs1_15.new(public_key).verify(ticket_hash, ticket.signature) 374 | return True 375 | except ValueError: 376 | return False 377 | -------------------------------------------------------------------------------- /src/libWiiPy/title/ticket.py: -------------------------------------------------------------------------------- 1 | # "title/ticket.py" from libWiiPy by NinjaCheetah & Contributors 2 | # https://github.com/NinjaCheetah/libWiiPy 3 | # 4 | # See https://wiibrew.org/wiki/Ticket for details about the ticket format 5 | 6 | import io 7 | import binascii 8 | import hashlib 9 | from dataclasses import dataclass as _dataclass 10 | from .crypto import decrypt_title_key 11 | from typing import List 12 | from .versions import title_ver_standard_to_dec 13 | 14 | 15 | @_dataclass 16 | class _TitleLimit: 17 | """ 18 | A TitleLimit object that contains the type of restriction and the limit. The limit type can be one of the following: 19 | 0 = None, 1 = Time Limit, 3 = None, or 4 = Launch Count. The maximum usage is then either the time in minutes the 20 | title can be played or the maximum number of launches allowed for that title, based on the type of limit applied. 21 | Private class used only by the Ticket class. 22 | 23 | Attributes 24 | ---------- 25 | limit_type : int 26 | The type of play limit applied. 0 and 3 are none, 1 is a time limit, and 4 is a launch count limit. 27 | maximum_usage : int 28 | The maximum value for the type of play limit applied. 29 | """ 30 | # The type of play limit applied. 31 | limit_type: int 32 | # The maximum value of the limit applied. 33 | maximum_usage: int 34 | 35 | 36 | class Ticket: 37 | """ 38 | A Ticket object that allows for either loading and editing an existing Ticket or creating one manually if desired. 39 | 40 | Attributes 41 | ---------- 42 | is_dev : bool 43 | Whether this Ticket is signed for development or not, and whether the Title Key is encrypted for development 44 | or not. 45 | signature : bytes 46 | The signature applied to the ticket. 47 | ticket_version : int 48 | The version of the ticket. 49 | title_key_enc : bytes 50 | The Title Key contained in the ticket, in encrypted form. 51 | ticket_id : bytes 52 | The unique ID of this ticket, used for console-specific title installations. 53 | console_id : int 54 | The unique ID of the console this ticket was designed for, if this is a console-specific ticket. 55 | title_version : int 56 | The version of the title this ticket was designed for. 57 | common_key_index : int 58 | The index of the common key required to decrypt this ticket's Title Key. 59 | """ 60 | def __init__(self) -> None: 61 | # If this is a dev ticket 62 | self.is_dev: bool = False # Defaults to false, set to true during load if this ticket is using dev certs. 63 | # Signature blob header 64 | self.signature_type: bytes = b'' # Type of signature, always 0x10001 for RSA-2048 65 | self.signature: bytes = b'' # Actual signature data 66 | # v0 ticket data 67 | self.signature_issuer: str = "" # Who issued the signature for the ticket 68 | self.ecdh_data: bytes = b'' # Involved in created one-time keys for console-specific title installs. 69 | self.ticket_version: int = 0 # The version of the current ticket file. 70 | self.title_key_enc: bytes = b'' # The title key of the ticket's respective title, encrypted by a common key. 71 | self.ticket_id: bytes = b'' # Used as the IV when decrypting the title key for console-specific title installs. 72 | self.console_id: int = 0 # ID of the console that the ticket was issued for. 73 | self.title_id: bytes = b'' # TID/IV used for AES-CBC encryption. 74 | self.unknown1: bytes = b'' # Some unknown data, not always the same so reading it just in case. 75 | self.title_version: int = 0 # Version of the ticket's associated title. 76 | self.permitted_titles: bytes = b'' # Permitted titles mask 77 | # "Permit mask. The current disc title is ANDed with the inverse of this mask to see if the result matches the 78 | # Permitted Titles Mask." -WiiBrew 79 | self.permit_mask: bytes = b'' 80 | self.title_export_allowed: int = 0 # Whether title export is allowed with a PRNG key or not. 81 | self.common_key_index: int = 0 # Which common key should be used. 0 = Common Key, 1 = Korean Key, 2 = vWii Key 82 | self.unknown2: bytes = b'' # More unknown data. Varies for VC/non-VC titles so reading it to ensure it matches. 83 | self.content_access_permissions: bytes = b'' # "Content access permissions (one bit for each content)" 84 | self.title_limits_list: List[_TitleLimit] = [] # List of play limits applied to the title. 85 | # v1 ticket data 86 | # TODO: Write in v1 ticket attributes here. This code can currently only handle v0 tickets, and will reject v1. 87 | 88 | def load(self, ticket: bytes) -> None: 89 | """ 90 | Loads raw Ticket data and sets all attributes of the WAD object. This allows for manipulating an already 91 | existing Ticket. 92 | 93 | Parameters 94 | ---------- 95 | ticket : bytes 96 | The data for the Ticket you wish to load. 97 | """ 98 | with io.BytesIO(ticket) as ticket_data: 99 | # ==================================================================================== 100 | # Parses each of the keys contained in the Ticket. 101 | # ==================================================================================== 102 | # Signature type. 103 | ticket_data.seek(0x0) 104 | self.signature_type = ticket_data.read(4) 105 | # Signature data. 106 | ticket_data.seek(0x04) 107 | self.signature = ticket_data.read(256) 108 | # Signature issuer. 109 | ticket_data.seek(0x140) 110 | self.signature_issuer = str(ticket_data.read(64).replace(b'\x00', b'').decode()) 111 | # ECDH data. 112 | ticket_data.seek(0x180) 113 | self.ecdh_data = ticket_data.read(60) 114 | # Ticket version. 115 | ticket_data.seek(0x1BC) 116 | self.ticket_version = int.from_bytes(ticket_data.read(1)) 117 | if self.ticket_version == 1: 118 | raise ValueError("This appears to be a v1 ticket, which is not currently supported by libWiiPy. This " 119 | "feature is planned for a later release. Only v0 tickets are supported at this time.") 120 | # Title Key (Encrypted by a common key). 121 | ticket_data.seek(0x1BF) 122 | self.title_key_enc = ticket_data.read(16) 123 | # Ticket ID. 124 | ticket_data.seek(0x1D0) 125 | self.ticket_id = ticket_data.read(8) 126 | # Console ID. 127 | ticket_data.seek(0x1D8) 128 | self.console_id = int.from_bytes(ticket_data.read(4)) 129 | # Title ID. 130 | ticket_data.seek(0x1DC) 131 | self.title_id = ticket_data.read(8) 132 | # Unknown data 1. 133 | ticket_data.seek(0x1E4) 134 | self.unknown1 = ticket_data.read(2) 135 | # Title version. 136 | ticket_data.seek(0x1E6) 137 | self.title_version = int.from_bytes(ticket_data.read(2)) 138 | # Permitted titles mask. 139 | ticket_data.seek(0x1E8) 140 | self.permitted_titles = ticket_data.read(4) 141 | # Permit mask. 142 | ticket_data.seek(0x1EC) 143 | self.permit_mask = ticket_data.read(4) 144 | # Whether title export with a PRNG key is allowed. 145 | ticket_data.seek(0x1F0) 146 | self.title_export_allowed = int.from_bytes(ticket_data.read(1)) 147 | # Common key index. 148 | ticket_data.seek(0x1F1) 149 | self.common_key_index = int.from_bytes(ticket_data.read(1)) 150 | # Unknown data 2. 151 | ticket_data.seek(0x1F2) 152 | self.unknown2 = ticket_data.read(48) 153 | # Content access permissions. 154 | ticket_data.seek(0x222) 155 | self.content_access_permissions = ticket_data.read(64) 156 | # Content limits. 157 | ticket_data.seek(0x264) 158 | for limit in range(0, 8): 159 | limit_type = int.from_bytes(ticket_data.read(4)) 160 | limit_value = int.from_bytes(ticket_data.read(4)) 161 | self.title_limits_list.append(_TitleLimit(limit_type, limit_value)) 162 | # Check certs to see if this is a retail or dev ticket. Treats unknown certs as being retail for now. 163 | if (self.signature_issuer.find("Root-CA00000002-XS00000006") != -1 or 164 | self.signature_issuer.find("Root-CA00000002-XS00000004") != -1): 165 | self.is_dev = True 166 | else: 167 | self.is_dev = False 168 | 169 | def dump(self) -> bytes: 170 | """ 171 | Dumps the Ticket object back into bytes. 172 | 173 | Returns 174 | ------- 175 | bytes 176 | The full Ticket file as bytes. 177 | """ 178 | ticket_data = b'' 179 | # Signature type. 180 | ticket_data += self.signature_type 181 | # Signature data. 182 | ticket_data += self.signature 183 | # Padding to 64 bytes. 184 | ticket_data += b'\x00' * 60 185 | # Signature issuer. 186 | signature_issuer = self.signature_issuer.encode() 187 | while len(signature_issuer) < 0x40: 188 | signature_issuer += b'\x00' 189 | ticket_data += signature_issuer 190 | # ECDH data. 191 | ticket_data += self.ecdh_data 192 | # Ticket version. 193 | ticket_data += int.to_bytes(self.ticket_version, 1) 194 | # Reserved (all \0x00). 195 | ticket_data += b'\x00\x00' 196 | # Title Key. 197 | ticket_data += self.title_key_enc 198 | # Unknown (write \0x00). 199 | ticket_data += b'\x00' 200 | # Ticket ID. 201 | ticket_data += self.ticket_id 202 | # Console ID. 203 | ticket_data += int.to_bytes(self.console_id, 4) 204 | # Title ID. 205 | ticket_data += self.title_id 206 | # Unknown data 1. 207 | ticket_data += self.unknown1 208 | # Title version. 209 | ticket_data += int.to_bytes(self.title_version, 2) 210 | # Permitted titles mask. 211 | ticket_data += self.permitted_titles 212 | # Permit mask. 213 | ticket_data += self.permit_mask 214 | # Title Export allowed. 215 | ticket_data += int.to_bytes(self.title_export_allowed, 1) 216 | # Common Key index. 217 | ticket_data += int.to_bytes(self.common_key_index, 1) 218 | # Unknown data 2. 219 | ticket_data += self.unknown2 220 | # Content access permissions. 221 | ticket_data += self.content_access_permissions 222 | # Padding (always \x00). 223 | ticket_data += b'\x00\x00' 224 | # Iterate over Title Limit objects, write them back into raw data, then add them to the Ticket. 225 | for title_limit in range(len(self.title_limits_list)): 226 | title_limit_data = b'' 227 | # Write all fields from the title limit entry. 228 | title_limit_data += int.to_bytes(self.title_limits_list[title_limit].limit_type, 4) 229 | title_limit_data += int.to_bytes(self.title_limits_list[title_limit].maximum_usage, 4) 230 | # Write the entry to the ticket. 231 | ticket_data += title_limit_data 232 | return ticket_data 233 | 234 | def fakesign(self) -> None: 235 | """ 236 | Fakesigns this Ticket for the trucha bug. 237 | 238 | This is done by brute-forcing a Ticket body hash starting with 00, causing it to pass signature verification on 239 | older IOS versions that incorrectly check the hash using strcmp() instead of memcmp(). The signature will also 240 | be erased and replaced with all NULL bytes. 241 | 242 | The hash is brute-forced by using the first two bytes of an unused section of the Ticket as a 16-bit integer, 243 | and incrementing that value by 1 until an appropriate hash is found. 244 | 245 | This modifies the Ticket object in place. You will need to call this method after any changes, and before 246 | dumping the Ticket object back into bytes. 247 | """ 248 | # Clear the signature, so that the hash derived from it is guaranteed to always be 249 | # '0000000000000000000000000000000000000000'. 250 | self.signature = b'\x00' * 256 251 | current_int = 0 252 | test_hash = '' 253 | while test_hash[:2] != '00': 254 | current_int += 1 255 | # We're using the first 2 bytes of this unused region of the Ticket as a 16-bit integer, and incrementing 256 | # that to brute-force the hash we need. 257 | data_to_edit = self.unknown2 258 | data_to_edit = int.to_bytes(current_int, 2) + data_to_edit[2:] 259 | self.unknown2 = data_to_edit 260 | # Trim off the first 320 bytes, because we're only looking for the hash of the Ticket's body. 261 | # This is a try-except because an OverflowError will be thrown if the number being used to brute-force the 262 | # hash gets too big, as it is only a 16-bit integer. If that happens, then fakesigning has failed. 263 | try: 264 | test_hash = hashlib.sha1(self.dump()[320:]).hexdigest() 265 | except OverflowError: 266 | raise Exception("An error occurred during fakesigning. Ticket could not be fakesigned!") 267 | 268 | def get_is_fakesigned(self) -> bool: 269 | """ 270 | Checks the Ticket object to see if it is currently fakesigned. For a description of fakesigning, refer to the 271 | fakesign() method. 272 | 273 | Returns 274 | ------- 275 | bool: 276 | True if the Ticket is fakesigned, False otherwise. 277 | 278 | See Also 279 | -------- 280 | libWiiPy.title.ticket.Ticket.fakesign() 281 | """ 282 | if self.signature != b'\x00' * 256: 283 | return False 284 | if hashlib.sha1(self.dump()[320:]).hexdigest()[:2] != '00': 285 | return False 286 | return True 287 | 288 | def get_title_id(self) -> str: 289 | """ 290 | Gets the Title ID of the ticket's associated title. 291 | 292 | Returns 293 | ------- 294 | str 295 | The Title ID of the title. 296 | """ 297 | return str(self.title_id.decode()) 298 | 299 | def get_common_key_type(self) -> str: 300 | """ 301 | Gets the name of the common key used to encrypt the Title Key contained in the ticket. 302 | 303 | Returns 304 | ------- 305 | str 306 | The name of the common key required. 307 | 308 | See Also 309 | -------- 310 | libWiiPy.title.commonkeys.get_common_key 311 | """ 312 | match self.common_key_index: 313 | case 0: 314 | return "Common" 315 | case 1: 316 | return "Korean" 317 | case 2: 318 | return "vWii" 319 | case _: 320 | return "Unknown" 321 | 322 | def get_title_key(self) -> bytes: 323 | """ 324 | Gets the decrypted title key contained in the ticket. 325 | 326 | Returns 327 | ------- 328 | bytes 329 | The decrypted title key. 330 | """ 331 | title_key = decrypt_title_key(self.title_key_enc, self.common_key_index, self.title_id, self.is_dev) 332 | return title_key 333 | 334 | def set_title_id(self, title_id) -> None: 335 | """ 336 | Sets the Title ID property of the Ticket. Recommended over setting the property directly because of input 337 | validation. 338 | 339 | Parameters 340 | ---------- 341 | title_id : str 342 | The new Title ID of the title. 343 | """ 344 | if len(title_id) != 16: 345 | raise ValueError("Invalid Title ID! Title IDs must be 8 bytes long.") 346 | self.title_id = binascii.unhexlify(title_id.encode()) 347 | 348 | def set_title_version(self, new_version: str | int) -> None: 349 | """ 350 | Sets the version of the title in the Ticket. Recommended over setting the data directly because of input 351 | validation. 352 | 353 | Accepts either standard form (vX.X) as a string or decimal form (vXXX) as an integer. 354 | 355 | Parameters 356 | ---------- 357 | new_version : str, int 358 | The new version of the title. See description for valid formats. 359 | """ 360 | if type(new_version) is str: 361 | # Validate string input is in the correct format, then validate that the version isn't higher than v255.0. 362 | # If checks pass, convert to decimal form and set that as the title version. 363 | version_str_split = new_version.split(".") 364 | if len(version_str_split) != 2: 365 | raise ValueError("Title version is not valid! String version must be entered in format \"X.X\".") 366 | if int(version_str_split[0]) > 255 or int(version_str_split[1]) > 255: 367 | raise ValueError("Title version is not valid! String version number cannot exceed v255.255.") 368 | version_converted = title_ver_standard_to_dec(new_version, str(self.title_id.decode())) 369 | self.title_version = version_converted 370 | elif type(new_version) is int: 371 | # Validate that the version isn't higher than 0xFFFF (v65535). 372 | if new_version > 0xFFFF: 373 | raise ValueError("Title version is not valid! Integer version number cannot exceed v65535.") 374 | self.title_version = new_version 375 | else: 376 | raise TypeError("Title version type is not valid! Type must be either integer or string.") 377 | -------------------------------------------------------------------------------- /src/libWiiPy/title/nus.py: -------------------------------------------------------------------------------- 1 | # "title/nus.py" from libWiiPy by NinjaCheetah & Contributors 2 | # https://github.com/NinjaCheetah/libWiiPy 3 | # 4 | # See https://wiibrew.org/wiki/NUS for details about the NUS 5 | 6 | import requests 7 | #import hashlib 8 | from typing import Any, List, Protocol 9 | #from urllib.parse import urlparse as _urlparse 10 | from .title import Title 11 | from .tmd import TMD 12 | from .ticket import Ticket 13 | 14 | _nus_endpoint = ["http://nus.cdn.shop.wii.com/ccs/download/", "http://ccs.cdn.wup.shop.nintendo.net/ccs/download/"] 15 | 16 | 17 | class DownloadCallback(Protocol): 18 | """ 19 | The format of a callable passed to a NUS download function. 20 | """ 21 | def __call__(self, done: int, total: int) -> Any: 22 | """ 23 | This function will be called with the current number of bytes downloaded and the total size of the file being 24 | downloaded. 25 | 26 | Parameters 27 | ---------- 28 | done : int 29 | The number of bytes already downloaded. 30 | total : int 31 | The total size of the file being downloaded. 32 | """ 33 | ... 34 | 35 | 36 | def download_title(title_id: str, title_version: int | None = None, wiiu_endpoint: bool = False, 37 | endpoint_override: str | None = None, progress: DownloadCallback = lambda done, total: None) -> Title: 38 | """ 39 | Download an entire title and all of its contents, then load the downloaded components into a Title object for 40 | further use. This method is NOT recommended for general use, as it has extremely limited verbosity. It is instead 41 | recommended to call the individual download methods instead to provide more flexibility and output. 42 | 43 | Be aware that you will receive fairly vague feedback from this function if you attach a progress callback. The 44 | callback will be connected to each of the individual functions called by this function, but there will be no 45 | indication of which function is currently running, just the progress of its download. 46 | 47 | Parameters 48 | ---------- 49 | title_id : str 50 | The Title ID of the title to download. 51 | title_version : int, optional 52 | The version of the title to download. Defaults to latest if not set. 53 | wiiu_endpoint : bool, optional 54 | Whether the Wii U endpoint for the NUS should be used or not. This increases download speeds. Defaults to False. 55 | endpoint_override: str, optional 56 | A custom endpoint URL to use instead of the standard Wii or Wii U endpoints. Defaults to no override, and if 57 | set entirely overrides the "wiiu_endpoint" parameter. 58 | progress: DownloadCallback, optional 59 | A callback function used to return the progress of the downloads. The provided callable must match the signature 60 | defined in DownloadCallback. 61 | 62 | Returns 63 | ------- 64 | Title 65 | A Title object containing all the data from the downloaded title. 66 | 67 | See Also 68 | -------- 69 | libWiiPy.title.nus.DownloadCallback 70 | """ 71 | # First, create the new title. 72 | title = Title() 73 | # Download and load the certificate chain, TMD, and Ticket. 74 | title.load_cert_chain(download_cert_chain(wiiu_endpoint, endpoint_override)) 75 | title.load_tmd(download_tmd(title_id, title_version, wiiu_endpoint, endpoint_override, progress)) 76 | title.load_ticket(download_ticket(title_id, wiiu_endpoint, endpoint_override, progress)) 77 | # Download all contents 78 | title.load_content_records() 79 | title.content.content_list = download_contents(title_id, title.tmd, wiiu_endpoint, endpoint_override, progress) 80 | # Return the completed title. 81 | return title 82 | 83 | 84 | def download_tmd(title_id: str, title_version: int | None = None, wiiu_endpoint: bool = False, 85 | endpoint_override: str | None = None, progress: DownloadCallback = lambda done, total: None) -> bytes: 86 | """ 87 | Downloads the TMD of the Title specified in the object. Will download the latest version by default, or another 88 | version if it was manually specified in the object. 89 | 90 | Parameters 91 | ---------- 92 | title_id : str 93 | The Title ID of the title to download the TMD for. 94 | title_version : int, option 95 | The version of the TMD to download. Defaults to latest if not set. 96 | wiiu_endpoint : bool, option 97 | Whether the Wii U endpoint for the NUS should be used or not. This increases download speeds. Defaults to False. 98 | endpoint_override: str, optional 99 | A custom endpoint URL to use instead of the standard Wii or Wii U endpoints. Defaults to no override, and if 100 | set entirely overrides the "wiiu_endpoint" parameter. 101 | progress: DownloadCallback, optional 102 | A callback function used to return the progress of the download. The provided callable must match the signature 103 | defined in DownloadCallback. 104 | 105 | Returns 106 | ------- 107 | bytes 108 | The TMD file from the NUS. 109 | 110 | See Also 111 | -------- 112 | libWiiPy.title.nus.DownloadCallback 113 | """ 114 | # Build the download URL. The structure is download//tmd for latest and download//tmd. for 115 | # when a specific version is requested. 116 | if endpoint_override is not None: 117 | endpoint_url = _validate_endpoint(endpoint_override) 118 | else: 119 | if wiiu_endpoint: 120 | endpoint_url = _nus_endpoint[1] 121 | else: 122 | endpoint_url = _nus_endpoint[0] 123 | tmd_url = endpoint_url + title_id + "/tmd" 124 | # Add the version to the URL if one was specified. 125 | if title_version is not None: 126 | tmd_url += "." + str(title_version) 127 | # Make the request. 128 | try: 129 | response = requests.get(url=tmd_url, headers={'User-Agent': 'wii libnup/1.0'}, stream=True) 130 | except requests.exceptions.ConnectionError: 131 | if endpoint_override: 132 | raise ValueError("A connection could not be made to the NUS endpoint. Please make sure that your endpoint " 133 | "override is valid.") 134 | else: 135 | raise Exception("A connection could not be made to the NUS endpoint. The NUS may be unavailable.") 136 | # Handle a 404 if the TID/version doesn't exist. 137 | if response.status_code == 404: 138 | raise ValueError("The requested Title ID or TMD version does not exist. Please check the Title ID and Title" 139 | " version and then try again.") 140 | elif response.status_code != 200: 141 | raise Exception(f"An unknown error occurred while downloading the TMD. " 142 | f"Got HTTP status code: {response.status_code}") 143 | total_size = int(response.headers["Content-Length"]) 144 | progress(0, total_size) 145 | # Stream the TMD's data in chunks so that we can post updates to the callback function (assuming one was supplied). 146 | raw_tmd = b"" 147 | for chunk in response.iter_content(512): 148 | raw_tmd += chunk 149 | progress(len(raw_tmd), total_size) 150 | # Use a TMD object to load the data and then return only the actual TMD. 151 | tmd_temp = TMD() 152 | tmd_temp.load(raw_tmd) 153 | tmd = tmd_temp.dump() 154 | return tmd 155 | 156 | 157 | def download_ticket(title_id: str, wiiu_endpoint: bool = False, endpoint_override: str | None = None, 158 | progress: DownloadCallback = lambda done, total: None) -> bytes: 159 | """ 160 | Downloads the Ticket of the Title specified in the object. This will only work if the Title ID specified is for 161 | a free title. 162 | 163 | Parameters 164 | ---------- 165 | title_id : str 166 | The Title ID of the title to download the Ticket for. 167 | wiiu_endpoint : bool, option 168 | Whether the Wii U endpoint for the NUS should be used or not. This increases download speeds. Defaults to False. 169 | endpoint_override: str, optional 170 | A custom endpoint URL to use instead of the standard Wii or Wii U endpoints. Defaults to no override, and if 171 | set entirely overrides the "wiiu_endpoint" parameter. 172 | progress: DownloadCallback, optional 173 | A callback function used to return the progress of the download. The provided callable must match the signature 174 | defined in DownloadCallback. 175 | 176 | Returns 177 | ------- 178 | bytes 179 | The Ticket file from the NUS. 180 | 181 | See Also 182 | -------- 183 | libWiiPy.title.nus.DownloadCallback 184 | """ 185 | # Build the download URL. The structure is download//cetk, and cetk will only exist if this is a free 186 | # title. 187 | if endpoint_override is not None: 188 | endpoint_url = _validate_endpoint(endpoint_override) 189 | else: 190 | if wiiu_endpoint: 191 | endpoint_url = _nus_endpoint[1] 192 | else: 193 | endpoint_url = _nus_endpoint[0] 194 | ticket_url = endpoint_url + title_id + "/cetk" 195 | # Make the request. 196 | try: 197 | response = requests.get(url=ticket_url, headers={'User-Agent': 'wii libnup/1.0'}, stream=True) 198 | except requests.exceptions.ConnectionError: 199 | if endpoint_override: 200 | raise ValueError("A connection could not be made to the NUS endpoint. Please make sure that your endpoint " 201 | "override is valid.") 202 | else: 203 | raise Exception("A connection could not be made to the NUS endpoint. The NUS may be unavailable.") 204 | if response.status_code == 404: 205 | raise ValueError("The requested Title ID does not exist, or refers to a non-free title. Tickets can only" 206 | " be downloaded for titles that are free on the NUS.") 207 | elif response.status_code != 200: 208 | raise Exception(f"An unknown error occurred while downloading the Ticket. " 209 | f"Got HTTP status code: {response.status_code}") 210 | total_size = int(response.headers["Content-Length"]) 211 | progress(0, total_size) 212 | # Stream the Ticket's data just like with the TMD. 213 | cetk = b"" 214 | for chunk in response.iter_content(chunk_size=1024): 215 | cetk += chunk 216 | progress(len(cetk), total_size) 217 | # Use a Ticket object to load only the Ticket data from cetk and return it. 218 | ticket_temp = Ticket() 219 | ticket_temp.load(cetk) 220 | ticket = ticket_temp.dump() 221 | return ticket 222 | 223 | 224 | def download_cert_chain(wiiu_endpoint: bool = False, endpoint_override: str | None = None) -> bytes: 225 | """ 226 | Downloads the signing certificate chain used by all WADs. This uses System Menu 4.3U as the source. 227 | 228 | Parameters 229 | ---------- 230 | wiiu_endpoint : bool, option 231 | Whether the Wii U endpoint for the NUS should be used or not. This increases download speeds. Defaults to False. 232 | endpoint_override: str, optional 233 | A custom endpoint URL to use instead of the standard Wii or Wii U endpoints. Defaults to no override, and if 234 | set entirely overrides the "wiiu_endpoint" parameter. 235 | 236 | Returns 237 | ------- 238 | bytes 239 | The cert file. 240 | """ 241 | # Download the TMD and cetk for System Menu 4.3U (v513). 242 | if endpoint_override is not None: 243 | endpoint_url = _validate_endpoint(endpoint_override) 244 | else: 245 | if wiiu_endpoint: 246 | endpoint_url = _nus_endpoint[1] 247 | else: 248 | endpoint_url = _nus_endpoint[0] 249 | tmd_url = endpoint_url + "0000000100000002/tmd.513" 250 | cetk_url = endpoint_url + "0000000100000002/cetk" 251 | try: 252 | tmd = requests.get(url=tmd_url, headers={'User-Agent': 'wii libnup/1.0'}, stream=True).content 253 | cetk = requests.get(url=cetk_url, headers={'User-Agent': 'wii libnup/1.0'}, stream=True).content 254 | except requests.exceptions.ConnectionError: 255 | if endpoint_override: 256 | raise ValueError("A connection could not be made to the NUS endpoint. Please make sure that your endpoint " 257 | "override is valid.") 258 | else: 259 | raise Exception("A connection could not be made to the NUS endpoint. The NUS may be unavailable.") 260 | # Assemble the certificate chain. 261 | cert_chain = b'' 262 | # Certificate Authority data. 263 | cert_chain += cetk[0x2A4 + 768:] 264 | # Certificate Policy (TMD certificate) data. 265 | cert_chain += tmd[0x328:0x328 + 768] 266 | # XS (Ticket certificate) data. 267 | cert_chain += cetk[0x2A4:0x2A4 + 768] 268 | # Since the cert chain is always the same, check the hash to make sure nothing went wildly wrong. 269 | # This is currently disabled because of the possibility that one may be downloading non-retail certs (gasp!). 270 | #if hashlib.sha1(cert_chain).hexdigest() != "ace0f15d2a851c383fe4657afc3840d6ffe30ad0": 271 | # raise Exception("An unknown error has occurred downloading and creating the certificate.") 272 | return cert_chain 273 | 274 | 275 | def download_content(title_id: str, content_id: int, wiiu_endpoint: bool = False, endpoint_override: str | None = None, 276 | progress: DownloadCallback = lambda done, total: None) -> bytes: 277 | """ 278 | Downloads a specified content for the title specified in the object. 279 | 280 | Parameters 281 | ---------- 282 | title_id : str 283 | The Title ID of the title to download content from. 284 | content_id : int 285 | The Content ID of the content you wish to download. 286 | wiiu_endpoint : bool, option 287 | Whether the Wii U endpoint for the NUS should be used or not. This increases download speeds. Defaults to False. 288 | endpoint_override: str, optional 289 | A custom endpoint URL to use instead of the standard Wii or Wii U endpoints. Defaults to no override, and if 290 | set entirely overrides the "wiiu_endpoint" parameter. 291 | progress: DownloadCallback, optional 292 | A callback function used to return the progress of the download. The provided callable must match the signature 293 | defined in DownloadCallback. 294 | 295 | Returns 296 | ------- 297 | bytes 298 | The downloaded content. 299 | 300 | See Also 301 | -------- 302 | libWiiPy.title.nus.DownloadCallback 303 | """ 304 | # Build the download URL. The structure is download//. 305 | if endpoint_override is not None: 306 | endpoint_url = _validate_endpoint(endpoint_override) 307 | else: 308 | if wiiu_endpoint: 309 | endpoint_url = _nus_endpoint[1] 310 | else: 311 | endpoint_url = _nus_endpoint[0] 312 | content_url = f"{endpoint_url}{title_id}/{content_id:08X}" 313 | # Make the request. 314 | try: 315 | response = requests.get(url=content_url, headers={'User-Agent': 'wii libnup/1.0'}, stream=True) 316 | except requests.exceptions.ConnectionError: 317 | if endpoint_override: 318 | raise ValueError("A connection could not be made to the NUS endpoint. Please make sure that your endpoint " 319 | "override is valid.") 320 | else: 321 | raise Exception("A connection could not be made to the NUS endpoint. The NUS may be unavailable.") 322 | if response.status_code == 404: 323 | raise ValueError(f"The requested Title ID does not exist, or an invalid Content ID is present in the" 324 | f" content records provided.\n Failed while downloading Content ID: {content_id:08X}") 325 | elif response.status_code != 200: 326 | raise Exception(f"An unknown error occurred while downloading the content. " 327 | f"Got HTTP status code: {response.status_code}") 328 | total_size = int(response.headers["Content-Length"]) 329 | progress(0, total_size) 330 | # Stream the content just like the TMD/Ticket. 331 | content = b"" 332 | for chunk in response.iter_content(chunk_size=1024): 333 | content += chunk 334 | progress(len(content), total_size) 335 | return content 336 | 337 | 338 | def download_contents(title_id: str, tmd: TMD, wiiu_endpoint: bool = False, endpoint_override: str | None = None, 339 | progress: DownloadCallback = lambda done, total: None) -> List[bytes]: 340 | """ 341 | Downloads all the contents for the title specified in the object. This requires a TMD to already be available 342 | so that the content records can be accessed. 343 | 344 | Parameters 345 | ---------- 346 | title_id : str 347 | The Title ID of the title to download content from. 348 | tmd : TMD 349 | The TMD that matches the title that the contents being downloaded are from. 350 | wiiu_endpoint : bool, option 351 | Whether the Wii U endpoint for the NUS should be used or not. This increases download speeds. Defaults to False. 352 | endpoint_override: str, optional 353 | A custom endpoint URL to use instead of the standard Wii or Wii U endpoints. Defaults to no override, and if 354 | set entirely overrides the "wiiu_endpoint" parameter. 355 | progress: DownloadCallback, optional 356 | A callback function used to return the progress of the downloads. The provided callable must match the signature 357 | defined in DownloadCallback. 358 | 359 | Returns 360 | ------- 361 | List[bytes] 362 | A list of all the downloaded contents. 363 | 364 | See Also 365 | -------- 366 | libWiiPy.title.nus.DownloadCallback 367 | """ 368 | # Retrieve the content records from the TMD. 369 | content_records = tmd.content_records 370 | # Create a list of Content IDs to download. 371 | content_ids = [] 372 | for content_record in content_records: 373 | content_ids.append(content_record.content_id) 374 | # Iterate over that list and download each content in it, then add it to the array of contents. 375 | content_list = [] 376 | for content_id in content_ids: 377 | # Call self.download_content() for each Content ID. 378 | content = download_content(title_id, content_id, wiiu_endpoint, endpoint_override, progress) 379 | content_list.append(content) 380 | return content_list 381 | 382 | 383 | def _validate_endpoint(endpoint: str) -> str: 384 | """ 385 | Validate the provided NUS endpoint URL and append the required path if necessary. 386 | 387 | Parameters 388 | ---------- 389 | endpoint: str 390 | The NUS endpoint URL to validate. 391 | 392 | Returns 393 | ------- 394 | str 395 | The validated NUS endpoint with the proper path. 396 | """ 397 | # Find the root of the URL and then assemble the correct URL based on that. 398 | # TODO: Rewrite in a way that makes more sense and un-stub 399 | #new_url = _urlparse(endpoint) 400 | #if new_url.netloc == "": 401 | # endpoint_url = "http://" + new_url.path + "/ccs/download/" 402 | #else: 403 | # endpoint_url = "http://" + new_url.netloc + "/ccs/download/" 404 | return endpoint 405 | -------------------------------------------------------------------------------- /src/libWiiPy/title/title.py: -------------------------------------------------------------------------------- 1 | # "title/title.py" from libWiiPy by NinjaCheetah & Contributors 2 | # https://github.com/NinjaCheetah/libWiiPy 3 | # 4 | # See https://wiibrew.org/wiki/Title for details about how titles are formatted 5 | 6 | import math 7 | 8 | from .cert import (CertificateChain as _CertificateChain, 9 | verify_ca_cert as _verify_ca_cert, verify_cert_sig as _verify_cert_sig, 10 | verify_tmd_sig as _verify_tmd_sig, verify_ticket_sig as _verify_ticket_sig) 11 | from .content import ContentRegion as _ContentRegion 12 | from .crypto import encrypt_title_key 13 | from .ticket import Ticket as _Ticket 14 | from .tmd import TMD as _TMD 15 | from .types import ContentType 16 | from .wad import WAD as _WAD 17 | 18 | 19 | class Title: 20 | """ 21 | A Title object that contains all components of a title, and allows altering them. Provides higher-level access 22 | than manually creating WAD, TMD, Ticket, and ContentRegion objects and ensures that any data that needs to match 23 | between files matches. 24 | 25 | Attributes 26 | ---------- 27 | wad: WAD 28 | A WAD object of a WAD containing the title's data. 29 | cert_chain: CertificateChain 30 | The chain of certificates used to verify the contents of a title. 31 | tmd: TMD 32 | A TMD object of the title's TMD. 33 | ticket: Ticket 34 | A Ticket object of the title's Ticket. 35 | content: ContentRegion 36 | A ContentRegion object containing the title's contents. 37 | """ 38 | def __init__(self) -> None: 39 | self.wad: _WAD = _WAD() 40 | self.cert_chain: _CertificateChain = _CertificateChain() 41 | self.tmd: _TMD = _TMD() 42 | self.ticket: _Ticket = _Ticket() 43 | self.content: _ContentRegion = _ContentRegion() 44 | 45 | def load_wad(self, wad: bytes) -> None: 46 | """ 47 | Load existing WAD data into the title and create WAD, TMD, Ticket, and ContentRegion objects based off of it 48 | to allow you to modify that data. Note that this will overwrite any existing data for this title. 49 | 50 | Parameters 51 | ---------- 52 | wad : bytes 53 | The data for the WAD you wish to load. 54 | """ 55 | # Create a new WAD object based on the WAD data provided. 56 | self.wad = _WAD() 57 | self.wad.load(wad) 58 | # Load the certificate chain. 59 | self.cert_chain = _CertificateChain() 60 | self.cert_chain.load(self.wad.get_cert_data()) 61 | # Load the TMD. 62 | self.tmd = _TMD() 63 | self.tmd.load(self.wad.get_tmd_data()) 64 | # Load the ticket. 65 | self.ticket = _Ticket() 66 | self.ticket.load(self.wad.get_ticket_data()) 67 | # Load the content. 68 | self.content = _ContentRegion() 69 | self.content.load(self.wad.get_content_data(), self.tmd.content_records) 70 | # Ensure that the Title IDs of the TMD and Ticket match before doing anything else. If they don't, throw an 71 | # error because clearly something strange has gone on with the WAD and editing it probably won't work. 72 | #if self.tmd.title_id != str(self.ticket.title_id.decode()): 73 | # raise ValueError("The Title IDs of the TMD and Ticket in this WAD do not match. This WAD appears to be " 74 | # "invalid.") 75 | 76 | def dump_wad(self) -> bytes: 77 | """ 78 | Dumps all title components (TMD, Ticket, and contents) back into the WAD object, and then dumps the WAD back 79 | into raw data and returns it. 80 | 81 | Returns 82 | ------- 83 | wad_data : bytes 84 | The raw data of the WAD. 85 | """ 86 | # Set WAD type to ib if the title being packed is boot2. 87 | if self.tmd.title_id == "0000000100000001": 88 | self.wad.wad_type = "ib" 89 | # Dump the certificate chain and set it in the WAD. 90 | self.wad.set_cert_data(self.cert_chain.dump()) 91 | # Dump the TMD and set it in the WAD. 92 | # This requires updating the content records and number of contents in the TMD first. 93 | self.tmd.content_records = self.content.content_records # This may not be needed because it's a ref already 94 | self.tmd.num_contents = len(self.content.content_records) 95 | self.wad.set_tmd_data(self.tmd.dump()) 96 | # Dump the Ticket and set it in the WAD. 97 | self.wad.set_ticket_data(self.ticket.dump()) 98 | # Dump the ContentRegion and set it in the WAD. 99 | content_data, content_size = self.content.dump() 100 | self.wad.set_content_data(content_data, content_size) 101 | return self.wad.dump() 102 | 103 | def load_cert_chain(self, cert_chain: bytes) -> None: 104 | """ 105 | Load an existing certificate chain into the title. Note that this will overwrite any existing certificate chain 106 | data for this title. 107 | 108 | Parameters 109 | ---------- 110 | cert_chain: bytes 111 | The data for the certificate chain to load. 112 | """ 113 | self.cert_chain.load(cert_chain) 114 | 115 | 116 | def load_tmd(self, tmd: bytes) -> None: 117 | """ 118 | Load existing TMD data into the title. Note that this will overwrite any existing TMD data for this title. 119 | 120 | Parameters 121 | ---------- 122 | tmd : bytes 123 | The data for the TMD to load. 124 | """ 125 | self.tmd.load(tmd) 126 | 127 | def load_ticket(self, ticket: bytes) -> None: 128 | """ 129 | Load existing Ticket data into the title. Note that this will overwrite any existing Ticket data for this 130 | title. 131 | 132 | Parameters 133 | ---------- 134 | ticket : bytes 135 | The data for the Ticket to load. 136 | """ 137 | self.ticket.load(ticket) 138 | 139 | def load_content_records(self) -> None: 140 | """ 141 | Load content records from the TMD into the ContentRegion to allow loading content files based on the records. 142 | This requires that a TMD has already been loaded and will throw an exception if it isn't. 143 | """ 144 | if not self.tmd.content_records: 145 | ValueError("No TMD appears to have been loaded, so content records cannot be read from it.") 146 | # Load the content records into the ContentRegion object, and update the number of contents. 147 | self.content.content_records = self.tmd.content_records 148 | self.content.num_contents = self.tmd.num_contents 149 | 150 | def set_title_id(self, title_id: str) -> None: 151 | """ 152 | Sets the Title ID of the title in both the TMD and Ticket. This also re-encrypts the Title Key as the Title Key 153 | is used as the IV for decrypting it. 154 | 155 | Parameters 156 | ---------- 157 | title_id : str 158 | The new Title ID of the title. 159 | """ 160 | if len(title_id) != 16: 161 | raise ValueError("Invalid Title ID! Title IDs must be 8 bytes long.") 162 | self.tmd.set_title_id(title_id) 163 | title_key_decrypted = self.ticket.get_title_key() 164 | self.ticket.set_title_id(title_id) 165 | title_key_encrypted = encrypt_title_key(title_key_decrypted, self.ticket.common_key_index, title_id, 166 | self.ticket.is_dev) 167 | self.ticket.title_key_enc = title_key_encrypted 168 | 169 | def set_title_version(self, title_version: str | int) -> None: 170 | """ 171 | Sets the version of the title in both the TMD and Ticket. 172 | 173 | Accepts either standard form (vX.X) as a string or decimal form (vXXX) as an integer. 174 | 175 | Parameters 176 | ---------- 177 | title_version : str, int 178 | The new version of the title. See description for valid formats. 179 | """ 180 | self.tmd.set_title_version(title_version) 181 | self.ticket.set_title_version(title_version) 182 | 183 | def get_content_by_index(self, index: int, skip_hash=False) -> bytes: 184 | """ 185 | Gets an individual content from the content region based on the provided index, in decrypted form. 186 | 187 | Parameters 188 | ---------- 189 | index : int 190 | The index of the content you want to get. 191 | skip_hash : bool, optional 192 | Skip the hash check and return the content regardless of its hash. Defaults to false. 193 | 194 | Returns 195 | ------- 196 | bytes 197 | The decrypted content listed in the content record. 198 | """ 199 | if self.ticket.title_id == "": 200 | raise ValueError("A Ticket must be loaded to get decrypted content.") 201 | dec_content = self.content.get_content_by_index(index, self.ticket.get_title_key(), skip_hash) 202 | return dec_content 203 | 204 | def get_content_by_cid(self, cid: int, skip_hash=False) -> bytes: 205 | """ 206 | Gets an individual content from the content region based on the provided Content ID, in decrypted form. 207 | 208 | Parameters 209 | ---------- 210 | cid : int 211 | The Content ID of the content you want to get. Expected to be in decimal form. 212 | skip_hash : bool, optional 213 | Skip the hash check and return the content regardless of its hash. Defaults to false. 214 | 215 | Returns 216 | ------- 217 | bytes 218 | The decrypted content listed in the content record. 219 | """ 220 | if self.ticket.title_id == "": 221 | raise ValueError("A Ticket must be loaded to get decrypted content.") 222 | dec_content = self.content.get_content_by_cid(cid, self.ticket.get_title_key(), skip_hash) 223 | return dec_content 224 | 225 | def get_title_size(self, absolute=False) -> int: 226 | """ 227 | Gets the installed size of the title, including the TMD and Ticket, in bytes. The "absolute" option determines 228 | whether shared content sizes should be included in the total size or not. This option defaults to False. 229 | 230 | Parameters 231 | ---------- 232 | absolute : bool, optional 233 | Whether shared contents should be included in the total size or not. Defaults to False. 234 | 235 | Returns 236 | ------- 237 | int 238 | The installed size of the title, in bytes. 239 | """ 240 | title_size = 0 241 | # Dumping and measuring the TMD and Ticket this way to ensure that any changes to them are measured properly. 242 | # Yes, the Ticket size should be a constant, but it's still good to check just in case. 243 | title_size += len(self.tmd.dump()) 244 | title_size += len(self.ticket.dump()) 245 | # For contents, get their sizes from the content records, because they store the intended sizes of the decrypted 246 | # contents, which are usually different from the encrypted sizes. 247 | for record in self.content.content_records: 248 | if record.content_type == ContentType.SHARED: 249 | if absolute: 250 | title_size += record.content_size 251 | else: 252 | title_size += record.content_size 253 | return title_size 254 | 255 | def get_title_size_blocks(self, absolute=False) -> int: 256 | """ 257 | Gets the installed size of the title, including the TMD and Ticket, in the Wii's displayed "blocks" format. The 258 | "absolute" option determines whether shared content sizes should be included in the total size or not. This 259 | option defaults to False. 260 | 261 | 1 Wii block is equal to 128KiB, and if any amount of a block is used, the entire block is considered used. 262 | 263 | Parameters 264 | ---------- 265 | absolute : bool, optional 266 | Whether shared contents should be included in the total size or not. Defaults to False. 267 | 268 | Returns 269 | ------- 270 | int 271 | The installed size of the title, in blocks. 272 | """ 273 | title_size_bytes = self.get_title_size(absolute) 274 | blocks = math.ceil(title_size_bytes / 131072) 275 | return blocks 276 | 277 | def add_enc_content(self, enc_content: bytes, cid: int, index: int, content_type: int, content_size: int, 278 | content_hash: bytes) -> None: 279 | """ 280 | Adds a new encrypted content to the ContentRegion, and adds the provided Content ID, index, content type, 281 | content size, and content hash to a new record in the ContentRecord list. 282 | 283 | Parameters 284 | ---------- 285 | enc_content : bytes 286 | The new encrypted content to add. 287 | cid : int 288 | The Content ID to assign the new content in the content record. 289 | index : int 290 | The index used when encrypting the new content. 291 | content_type : int 292 | The type of the new content. 293 | content_size : int 294 | The size of the new encrypted content when decrypted. 295 | content_hash : bytes 296 | The hash of the new encrypted content when decrypted. 297 | """ 298 | # Add the encrypted content. 299 | self.content.add_enc_content(enc_content, cid, index, content_type, content_size, content_hash) 300 | # Update the TMD to match. 301 | self.tmd.content_records = self.content.content_records 302 | 303 | def add_content(self, dec_content: bytes, cid: int, content_type: int) -> None: 304 | """ 305 | Adds a new decrypted content to the end of the ContentRegion, and adds the provided Content ID, content type, 306 | content size, and content hash to a new record in the ContentRecord list. The index will be automatically 307 | assigned by incrementing the current highest index in the records. 308 | 309 | This first gets the content hash and size from the provided data, and then encrypts the content with the 310 | Title Key before adding it to the ContentRegion. 311 | 312 | Parameters 313 | ---------- 314 | dec_content : bytes 315 | The new decrypted content to add. 316 | cid : int 317 | The Content ID to assign the new content in the content record. 318 | content_type : int 319 | The type of the new content. 320 | """ 321 | # Add the decrypted content. 322 | self.content.add_content(dec_content, cid, content_type, self.ticket.get_title_key()) 323 | # Update the TMD to match. 324 | self.tmd.content_records = self.content.content_records 325 | 326 | def set_enc_content(self, enc_content: bytes, index: int, content_size: int, content_hash: bytes, 327 | cid: int | None = None, content_type: int | None = None) -> None: 328 | """ 329 | Sets the content at the provided index to the provided new encrypted content. The provided hash and content size 330 | are set in the corresponding content record. A new Content ID or content type can also be specified, but if it 331 | isn't then the current values are preserved. 332 | 333 | This also updates the content records in the TMD after the content is set. 334 | 335 | Parameters 336 | ---------- 337 | enc_content : bytes 338 | The new encrypted content to set. 339 | index : int 340 | The index to place the new content at. 341 | content_size : int 342 | The size of the new encrypted content when decrypted. 343 | content_hash : bytes 344 | The hash of the new encrypted content when decrypted. 345 | cid : int 346 | The Content ID to assign the new content in the content record. 347 | content_type : int 348 | The type of the new content. 349 | """ 350 | # Set the encrypted content. 351 | self.content.set_enc_content(enc_content, index, content_size, content_hash, cid, content_type) 352 | # Update the TMD to match. 353 | self.tmd.content_records = self.content.content_records 354 | 355 | def set_content(self, dec_content: bytes, index: int, cid: int | None = None, 356 | content_type: int | None = None) -> None: 357 | """ 358 | Sets the content at the provided index to the provided new decrypted content. The hash and content size of this 359 | content will be generated and then set in the corresponding content record. A new Content ID or content type can 360 | also be specified, but if it isn't then the current values are preserved. 361 | 362 | This also updates the content records in the TMD after the content is set. 363 | 364 | Parameters 365 | ---------- 366 | dec_content : bytes 367 | The new decrypted content to set. 368 | index : int 369 | The index to place the new content at. 370 | cid : int, optional 371 | The Content ID to assign the new content in the content record. 372 | content_type : int, optional 373 | The type of the new content. 374 | """ 375 | # Set the decrypted content. 376 | self.content.set_content(dec_content, index, self.ticket.get_title_key(), cid, content_type) 377 | # Update the TMD to match. 378 | self.tmd.content_records = self.content.content_records 379 | 380 | def load_content(self, dec_content: bytes, index: int) -> None: 381 | """ 382 | Loads the provided decrypted content into the ContentRegion at the specified index, but first checks to make 383 | sure that it matches the corresponding record. This content will then be encrypted using the title's Title Key 384 | before being loaded. 385 | 386 | Parameters 387 | ---------- 388 | dec_content : bytes 389 | The decrypted content to load. 390 | index : int 391 | The index to load the content at. 392 | """ 393 | # Load the decrypted content. 394 | self.content.load_content(dec_content, index, self.ticket.get_title_key()) 395 | 396 | def fakesign(self) -> None: 397 | """ 398 | Fakesigns this Title for the trucha bug. 399 | 400 | This is done by brute-forcing a TMD and Ticket body hash starting with 00, causing it to pass signature 401 | verification on older IOS versions that incorrectly check the hash using strcmp() instead of memcmp(). The TMD 402 | and Ticket signatures will also be erased and replaced with all NULL bytes. 403 | 404 | This modifies the TMD and Ticket objects that are part of this Title in place. You will need to call this method 405 | after any changes to the TMD or Ticket, and before dumping the Title object into a WAD to ensure that the WAD 406 | is properly fakesigned. 407 | """ 408 | self.tmd.num_contents = self.content.num_contents # This needs to be updated in case it was changed 409 | self.tmd.fakesign() 410 | self.ticket.fakesign() 411 | 412 | def get_is_fakesigned(self): 413 | """ 414 | Checks the Title object to see if it is currently fakesigned. This ensures that both the TMD and Ticket are 415 | fakesigned. For a description of fakesigning, refer to the fakesign() method. 416 | 417 | Returns 418 | ------- 419 | bool: 420 | True if the Title is fakesigned, False otherwise. 421 | 422 | See Also 423 | -------- 424 | libWiiPy.title.title.Title.fakesign() 425 | """ 426 | if self.tmd.get_is_fakesigned and self.ticket.get_is_fakesigned(): 427 | return True 428 | else: 429 | return False 430 | 431 | def get_is_signed(self) -> bool: 432 | """ 433 | Uses the certificate chain to verify whether the Title object contains a properly signed title or not. This 434 | verifies both the TMD and Ticket, and if either one fails verification then the title is not considered valid. 435 | 436 | This will validate the entire certificate chain. If any part of the chain doesn't match the other pieces, then 437 | this method will raise an exception. 438 | 439 | Returns 440 | ------- 441 | bool 442 | Whether the title is properly signed or not. 443 | 444 | See Also 445 | -------- 446 | libWiiPy.title.cert 447 | """ 448 | # I did not understand short-circuiting when I originally wrote this code, and it was 5 nested if statements 449 | # which looked silly. I now understand that this is functionally identical! 450 | try: 451 | if _verify_ca_cert(self.cert_chain.ca_cert) and \ 452 | _verify_cert_sig(self.cert_chain.ca_cert, self.cert_chain.tmd_cert) and \ 453 | _verify_tmd_sig(self.cert_chain.tmd_cert, self.tmd) and \ 454 | _verify_cert_sig(self.cert_chain.ca_cert, self.cert_chain.ticket_cert) and \ 455 | _verify_ticket_sig(self.cert_chain.ticket_cert, self.ticket): 456 | return True 457 | except ValueError: 458 | raise ValueError("This title's certificate chain is not valid, or does not match the signature type of " 459 | "the TMD/Ticket.") 460 | return False 461 | --------------------------------------------------------------------------------