2 Commits

Author SHA1 Message Date
374358711b Beginning libWiiPy refactors
No major functional changes have been made so far other than a couple of things being shifted between files, but a lot of bad code has been rewritten to hopefully make libWiiPy maintainable into the future.
2025-12-16 23:14:33 -05:00
ce5d118de1 Create cert.sys during EmuNAND title installation if not found 2025-07-12 13:03:42 -04:00
19 changed files with 301 additions and 207 deletions

View File

@@ -16,8 +16,9 @@ The `libWiiPy.title` package contains modules for interacting with Wii titles. T
| [libWiiPy.title.ticket](/title/ticket) | Provides support for parsing and editing Tickets used for content decryption |
| [libWiiPy.title.title](/title/title.title) | Provides high-level support for parsing and editing an entire title with the context of each component |
| [libWiiPy.title.tmd](/title/tmd) | Provides support for parsing and editing TMDs (Title Metadata) |
| [libWiiPy.title.util](/title/util) | Provides some simple utility functions relating to titles |
| [libWiiPy.title.wad](/title/wad) | Provides support for parsing and editing WAD files, allowing you to load each component into the other available classes |
| [libWiiPy.title.types](/title/types) | Provides shared types used across the title module. |
| [libWiiPy.title.versions](/title/versions) | Provides utility functions for converting the format that a title's version is in. |
## Full Package Contents
@@ -33,6 +34,7 @@ The `libWiiPy.title` package contains modules for interacting with Wii titles. T
/title/ticket
/title/title.title
/title/tmd
/title/util
/title/wad
/title/types
/title/versions
```

View File

@@ -0,0 +1,14 @@
# libWiiPy.title.types Module
## Description
The `libWiiPy.title.types` module provides shared types used across the title module.
## Module Contents
```{eval-rst}
.. automodule:: libWiiPy.title.types
:members:
:undoc-members:
:show-inheritance:
```

View File

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

View File

@@ -0,0 +1,14 @@
# libWiiPy.title.versions Module
## Description
The `libWiiPy.title.versions` module provides functions for converting the format that a title's version is in.
## Module Contents
```{eval-rst}
.. automodule:: libWiiPy.title.versions
:members:
:undoc-members:
:show-inheritance:
```

65
src/libWiiPy/constants.py Normal file
View File

@@ -0,0 +1,65 @@
# "constants.py" from libWiiPy by NinjaCheetah & Contributors
# https://github.com/NinjaCheetah/libWiiPy
#
# This file defines constant values referenced across the library.
_WII_MENU_VERSIONS = {
"Prelaunch": [0, 1, 2],
"1.0J": 64,
"1.0U": 33,
"1.0E": 34,
"2.0J": 128,
"2.0U": 97,
"2.0E": 130,
"2.1E": 162,
"2.2J": 192,
"2.2U": 193,
"2.2E": 194,
"3.0J": 224,
"3.0U": 225,
"3.0E": 226,
"3.1J": 256,
"3.1U": 257,
"3.1E": 258,
"3.2J": 288,
"3.2U": 289,
"3.2E": 290,
"3.3J": 352,
"3.3U": 353,
"3.3E": 354,
"3.3K": 326,
"3.4J": 384,
"3.4U": 385,
"3.4E": 386,
"3.5K": 390,
"4.0J": 416,
"4.0U": 417,
"4.0E": 418,
"4.1J": 448,
"4.1U": 449,
"4.1E": 450,
"4.1K": 454,
"4.2J": 480,
"4.2U": 481,
"4.2E": 482,
"4.2K": 486,
"4.3J": 512,
"4.3U": 513,
"4.3E": 514,
"4.3K": 518,
"4.3U-Mini": 4609,
"4.3E-Mini": 4610
}
_VWII_MENU_VERSIONS = {
"vWii-1.0.0J": 512,
"vWii-1.0.0U": 513,
"vWii-1.0.0E": 514,
"vWii-4.0.0J": 544,
"vWii-4.0.0U": 545,
"vWii-4.0.0E": 546,
"vWii-5.2.0J": 608,
"vWii-5.2.0U": 609,
"vWii-5.2.0E": 610,
}

View File

@@ -8,6 +8,7 @@ import pathlib
import shutil
from dataclasses import dataclass as _dataclass
from typing import Callable, List
from ..title.ticket import Ticket
from ..title.title import Title
from ..title.tmd import TMD
@@ -34,7 +35,7 @@ class EmuNAND:
"""
def __init__(self, emunand_root: str | pathlib.Path, callback: Callable | None = None):
self.emunand_root = pathlib.Path(emunand_root)
self.log = callback if callback is not None else None
self.log = callback if callback is not None else lambda x: None
self.import_dir = self.emunand_root.joinpath("import")
self.meta_dir = self.emunand_root.joinpath("meta")
@@ -70,12 +71,14 @@ class EmuNAND:
skip_hash : bool, optional
Skip the hash check and install the title regardless of its hashes. Defaults to false.
"""
self.log(f"[PROGRESS] Starting install of title with Title ID {title.tmd.title_id}...")
# Save the upper and lower portions of the Title ID, because these are used as target install directories.
tid_upper = title.tmd.title_id[:8]
tid_lower = title.tmd.title_id[8:]
# Tickets are installed as <tid_lower>.tik in /ticket/<tid_upper>/
ticket_dir = self.ticket_dir.joinpath(tid_upper)
self.log(f"[PROGRESS] Installing ticket to \"{ticket_dir}\"...")
ticket_dir.mkdir(exist_ok=True)
ticket_dir.joinpath(f"{tid_lower}.tik").write_bytes(title.ticket.dump())
@@ -86,19 +89,25 @@ class EmuNAND:
title_dir = title_dir.joinpath(tid_lower)
title_dir.mkdir(exist_ok=True)
content_dir = title_dir.joinpath("content")
self.log(f"[PROGRESS] Installing TMD to \"{content_dir}\"...")
if content_dir.exists():
shutil.rmtree(content_dir) # Clear the content directory so old contents aren't left behind.
content_dir.mkdir(exist_ok=True)
content_dir.joinpath("title.tmd").write_bytes(title.tmd.dump())
self.log(f"[PROGRESS] Installing content to \"{content_dir}\"...")
if skip_hash:
self.log("[WARN] Not checking content hashes! Content validity will not be verified.")
for content_file in range(0, title.tmd.num_contents):
if title.tmd.content_records[content_file].content_type == 1:
content_file_name = f"{title.tmd.content_records[content_file].content_id:08X}".lower()
self.log(f"[PROGRESS] Installing content \"{content_file_name}.app\" to \"{content_dir}\"... ")
content_dir.joinpath(f"{content_file_name}.app").write_bytes(
title.get_content_by_index(content_file, skip_hash=skip_hash))
title_dir.joinpath("data").mkdir(exist_ok=True) # Empty directory used for save data for the title.
# Shared contents need to be installed to /shared1/, with incremental names determined by /shared1/content.map.
content_map_path = self.shared1_dir.joinpath("content.map")
self.log(f"[PROGRESS] Installing shared content to \"{self.shared1_dir}\"...")
content_map = _SharedContentMap()
existing_hashes = []
if content_map_path.exists():
@@ -108,7 +117,10 @@ class EmuNAND:
for content_file in range(0, title.tmd.num_contents):
if title.tmd.content_records[content_file].content_type == 32769:
if title.tmd.content_records[content_file].content_hash not in existing_hashes:
self.log(f"[PROGRESS] Adding shared content hash to content.map...")
content_file_name = content_map.add_content(title.tmd.content_records[content_file].content_hash)
self.log(f"[PROGRESS] Installing shared content \"{content_file_name}.app\" to "
f"\"{self.shared1_dir}\"...")
self.shared1_dir.joinpath(f"{content_file_name}.app").write_bytes(
title.get_content_by_index(content_file, skip_hash=skip_hash))
self.shared1_dir.joinpath("content.map").write_bytes(content_map.dump())
@@ -120,6 +132,7 @@ class EmuNAND:
meta_dir = self.meta_dir.joinpath(tid_upper)
meta_dir.mkdir(exist_ok=True)
meta_dir = meta_dir.joinpath(tid_lower)
self.log(f"[PROGRESS] Installing meta data to \"{meta_dir}\"...")
meta_dir.mkdir(exist_ok=True)
meta_dir.joinpath("title.met").write_bytes(title.wad.get_meta_data())
@@ -127,12 +140,26 @@ class EmuNAND:
uid_sys_path = self.sys_dir.joinpath("uid.sys")
uid_sys = _UidSys()
if not uid_sys_path.exists():
self.log("[WARN] uid.sys does not exist! Creating it with the default entry.")
uid_sys.create()
else:
uid_sys.load(uid_sys_path.read_bytes())
self.log("[PROGRESS] Adding title to uid.sys and assigning a new UID...")
uid_sys.add(title.tmd.title_id)
uid_sys_path.write_bytes(uid_sys.dump())
# Check for a cert.sys and initialize it using the certs in the WAD if it doesn't exist.
cert_sys_path = self.sys_dir.joinpath("cert.sys")
if not cert_sys_path.exists():
self.log("[WARN] cert.sys does not exist! Creating it using certs from the installed title...")
cert_sys_data = b''
cert_sys_data += title.cert_chain.ticket_cert.dump()
cert_sys_data += title.cert_chain.ca_cert.dump()
cert_sys_data += title.cert_chain.tmd_cert.dump()
cert_sys_path.write_bytes(cert_sys_data)
self.log("[PROGRESS] Completed title installation.")
def uninstall_title(self, tid: str) -> None:
"""
Uninstall the Title with the specified Title ID from the EmuNAND. This will leave shared contents unmodified.

View File

@@ -8,7 +8,7 @@ from typing import List
from ..shared import _pad_bytes
_key = 0x73B5DBFA
_KEY = 0x73B5DBFA
class SettingTxt:
"""
@@ -53,11 +53,11 @@ class SettingTxt:
The data of an encrypted setting.txt file.
"""
with io.BytesIO(setting_txt) as setting_data:
global _key # I still don't actually know what *kind* of encryption this is.
global _KEY # I still don't actually know what *kind* of encryption this is.
setting_txt_dec: List[int] = []
for i in range(0, 256):
setting_txt_dec.append(int.from_bytes(setting_data.read(1)) ^ (_key & 0xff))
_key = (_key << 1) | (_key >> 31)
setting_txt_dec.append(int.from_bytes(setting_data.read(1)) ^ (_KEY & 0xff))
_KEY = (_KEY << 1) | (_KEY >> 31)
setting_txt_bytes = bytes(setting_txt_dec)
try:
setting_str = setting_txt_bytes.decode('utf-8')
@@ -103,13 +103,13 @@ class SettingTxt:
"""
setting_str = self.dump_decrypted()
setting_txt_dec = setting_str.encode()
global _key
global _KEY
# This could probably be made more efficient somehow.
setting_txt_enc: List[int] = []
with io.BytesIO(setting_txt_dec) as setting_data:
for i in range(0, len(setting_txt_dec)):
setting_txt_enc.append(int.from_bytes(setting_data.read(1)) ^ (_key & 0xff))
_key = (_key << 1) | (_key >> 31)
setting_txt_enc.append(int.from_bytes(setting_data.read(1)) ^ (_KEY & 0xff))
_KEY = (_KEY << 1) | (_KEY >> 31)
setting_txt_bytes = _pad_bytes(bytes(setting_txt_enc), 256)
return setting_txt_bytes

View File

@@ -47,69 +47,3 @@ def _pad_bytes(data, alignment=64) -> bytes:
while (len(data) % alignment) != 0:
data += b'\x00'
return data
def _bitmask(x: int) -> int:
return 1 << x
_wii_menu_versions = {
"Prelaunch": [0, 1, 2],
"1.0J": 64,
"1.0U": 33,
"1.0E": 34,
"2.0J": 128,
"2.0U": 97,
"2.0E": 130,
"2.1E": 162,
"2.2J": 192,
"2.2U": 193,
"2.2E": 194,
"3.0J": 224,
"3.0U": 225,
"3.0E": 226,
"3.1J": 256,
"3.1U": 257,
"3.1E": 258,
"3.2J": 288,
"3.2U": 289,
"3.2E": 290,
"3.3J": 352,
"3.3U": 353,
"3.3E": 354,
"3.3K": 326,
"3.4J": 384,
"3.4U": 385,
"3.4E": 386,
"3.5K": 390,
"4.0J": 416,
"4.0U": 417,
"4.0E": 418,
"4.1J": 448,
"4.1U": 449,
"4.1E": 450,
"4.1K": 454,
"4.2J": 480,
"4.2U": 481,
"4.2E": 482,
"4.2K": 486,
"4.3J": 512,
"4.3U": 513,
"4.3E": 514,
"4.3K": 518,
"4.3U-Mini": 4609,
"4.3E-Mini": 4610
}
_vwii_menu_versions = {
"vWii-1.0.0J": 512,
"vWii-1.0.0U": 513,
"vWii-1.0.0E": 514,
"vWii-4.0.0J": 544,
"vWii-4.0.0U": 545,
"vWii-4.0.0E": 546,
"vWii-5.2.0J": 608,
"vWii-5.2.0U": 609,
"vWii-5.2.0E": 610,
}

View File

@@ -9,5 +9,6 @@ from .nus import *
from .ticket import *
from .title import *
from .tmd import *
from .util import *
from .types import *
from .versions import *
from .wad import *

View File

@@ -5,33 +5,47 @@
import io
from enum import IntEnum as _IntEnum
from ..shared import _align_value, _pad_bytes
from .ticket import Ticket
from .tmd import TMD
from Crypto.Hash import SHA1
from Crypto.PublicKey import RSA
from Crypto.Signature import pkcs1_15
from ..shared import _align_value, _pad_bytes
from .ticket import Ticket
from .tmd import TMD
class CertificateType(_IntEnum):
"""
The type of a certificate.
"""
RSA_4096 = 0x00010000
RSA_2048 = 0x00010001
ECC = 0x00010002
class CertificateSignatureLength(_IntEnum):
"""
The length of a certificate's signature.
"""
RSA_4096 = 0x200
RSA_2048 = 0x100
ECC = 0x3C
class CertificateKeyType(_IntEnum):
"""
The type of key contained in a certificate.
"""
RSA_4096 = 0x00000000
RSA_2048 = 0x00000001
ECC = 0x00000002
class CertificateKeyLength(_IntEnum):
"""
The length of the key contained in a certificate.
"""
RSA_4096 = 0x200
RSA_2048 = 0x100
ECC = 0x3C

View File

@@ -33,13 +33,12 @@ def get_common_key(common_key_index, dev=False) -> bytes:
match common_key_index:
case 0:
if dev:
common_key_bin = binascii.unhexlify(development_key)
return binascii.unhexlify(development_key)
else:
common_key_bin = binascii.unhexlify(common_key)
return binascii.unhexlify(common_key)
case 1:
common_key_bin = binascii.unhexlify(korean_key)
return binascii.unhexlify(korean_key)
case 2:
common_key_bin = binascii.unhexlify(vwii_key)
return binascii.unhexlify(vwii_key)
case _:
common_key_bin = binascii.unhexlify(common_key)
return common_key_bin
return binascii.unhexlify(common_key)

View File

@@ -9,7 +9,7 @@ import hashlib
from typing import List
from dataclasses import dataclass as _dataclass
from enum import IntEnum as _IntEnum
from ..types import _ContentRecord
from .types import ContentRecord
from ..shared import _pad_bytes, _align_value
from .crypto import decrypt_content, encrypt_content
@@ -28,20 +28,20 @@ class ContentRegion:
Attributes
----------
content_records : List[_ContentRecord]
content_records : List[ContentRecord]
The content records for the content stored in the region.
num_contents : int
The total number of contents stored in the region.
"""
def __init__(self) -> None:
self.content_records: List[_ContentRecord] = []
self.content_records: List[ContentRecord] = []
self.content_region_size: int = 0 # Size of the content region.
self.num_contents: int = 0 # Number of contents in the content region.
self.content_start_offsets: List[int] = [0] # The start offsets of each content in the content region.
self.content_list: List[bytes] = []
def load(self, content_region: bytes, content_records: List[_ContentRecord]) -> None:
def load(self, content_region: bytes, content_records: List[ContentRecord]) -> None:
"""
Loads the raw content region and builds a list of all the contents.
@@ -49,7 +49,7 @@ class ContentRegion:
----------
content_region : bytes
The raw data for the content region being loaded.
content_records : list[_ContentRecord]
content_records : list[ContentRecord]
A list of ContentRecord objects detailing all contents contained in the region.
"""
self.content_records = content_records
@@ -303,7 +303,7 @@ class ContentRegion:
raise ValueError("Content with an index of " + str(index) + " already exists!")
# If we're good, then append all the data and create a new ContentRecord().
self.content_list.append(enc_content)
self.content_records.append(_ContentRecord(cid, index, content_type, content_size, content_hash))
self.content_records.append(ContentRecord(cid, index, content_type, content_size, content_hash))
self.num_contents += 1
def add_content(self, dec_content: bytes, cid: int, content_type: int, title_key: bytes) -> None:

View File

@@ -10,7 +10,6 @@ from Crypto.Cipher import AES as _AES
def _convert_tid_to_iv(title_id: str | bytes) -> bytes:
# Converts a Title ID in various formats into the format required to act as an IV. Private function used by other
# crypto functions.
title_key_iv = b''
if type(title_id) is bytes:
# This catches the format b'0000000100000002'
if len(title_id) == 16:

View File

@@ -134,9 +134,12 @@ def download_tmd(title_id: str, title_version: int | None = None, wiiu_endpoint:
else:
raise Exception("A connection could not be made to the NUS endpoint. The NUS may be unavailable.")
# Handle a 404 if the TID/version doesn't exist.
if response.status_code != 200:
if response.status_code == 404:
raise ValueError("The requested Title ID or TMD version does not exist. Please check the Title ID and Title"
" version and then try again.")
elif response.status_code != 200:
raise Exception(f"An unknown error occurred while downloading the TMD. "
f"Got HTTP status code: {response.status_code}")
total_size = int(response.headers["Content-Length"])
progress(0, total_size)
# Stream the TMD's data in chunks so that we can post updates to the callback function (assuming one was supplied).
@@ -198,9 +201,12 @@ def download_ticket(title_id: str, wiiu_endpoint: bool = False, endpoint_overrid
"override is valid.")
else:
raise Exception("A connection could not be made to the NUS endpoint. The NUS may be unavailable.")
if response.status_code != 200:
if response.status_code == 404:
raise ValueError("The requested Title ID does not exist, or refers to a non-free title. Tickets can only"
" be downloaded for titles that are free on the NUS.")
elif response.status_code != 200:
raise Exception(f"An unknown error occurred while downloading the Ticket. "
f"Got HTTP status code: {response.status_code}")
total_size = int(response.headers["Content-Length"])
progress(0, total_size)
# Stream the Ticket's data just like with the TMD.
@@ -296,9 +302,6 @@ def download_content(title_id: str, content_id: int, wiiu_endpoint: bool = False
libWiiPy.title.nus.DownloadCallback
"""
# Build the download URL. The structure is download/<TID>/<Content ID>.
content_id_hex = hex(content_id)[2:]
if len(content_id_hex) < 2:
content_id_hex = "0" + content_id_hex
if endpoint_override is not None:
endpoint_url = _validate_endpoint(endpoint_override)
else:
@@ -306,7 +309,7 @@ def download_content(title_id: str, content_id: int, wiiu_endpoint: bool = False
endpoint_url = _nus_endpoint[1]
else:
endpoint_url = _nus_endpoint[0]
content_url = endpoint_url + title_id + "/000000" + content_id_hex
content_url = f"{endpoint_url}{title_id}/{content_id:08X}"
# Make the request.
try:
response = requests.get(url=content_url, headers={'User-Agent': 'wii libnup/1.0'}, stream=True)
@@ -316,10 +319,12 @@ def download_content(title_id: str, content_id: int, wiiu_endpoint: bool = False
"override is valid.")
else:
raise Exception("A connection could not be made to the NUS endpoint. The NUS may be unavailable.")
if response.status_code != 200:
raise ValueError("The requested Title ID does not exist, or an invalid Content ID is present in the"
" content records provided.\n Failed while downloading Content ID: 000000" +
content_id_hex)
if response.status_code == 404:
raise ValueError(f"The requested Title ID does not exist, or an invalid Content ID is present in the"
f" content records provided.\n Failed while downloading Content ID: {content_id:08X}")
elif response.status_code != 200:
raise Exception(f"An unknown error occurred while downloading the content. "
f"Got HTTP status code: {response.status_code}")
total_size = int(response.headers["Content-Length"])
progress(0, total_size)
# Stream the content just like the TMD/Ticket.

View File

@@ -9,7 +9,7 @@ import hashlib
from dataclasses import dataclass as _dataclass
from .crypto import decrypt_title_key
from typing import List
from .util import title_ver_standard_to_dec
from .versions import title_ver_standard_to_dec
@_dataclass
@@ -281,8 +281,7 @@ class Ticket:
"""
if self.signature != b'\x00' * 256:
return False
test_hash = hashlib.sha1(self.dump()[320:]).hexdigest()
if test_hash[:2] != '00':
if hashlib.sha1(self.dump()[320:]).hexdigest()[:2] != '00':
return False
return True
@@ -295,8 +294,7 @@ class Ticket:
str
The Title ID of the title.
"""
title_id_str = str(self.title_id.decode())
return title_id_str
return str(self.title_id.decode())
def get_common_key_type(self) -> str:
"""
@@ -370,8 +368,8 @@ class Ticket:
version_converted = title_ver_standard_to_dec(new_version, str(self.title_id.decode()))
self.title_version = version_converted
elif type(new_version) is int:
# Validate that the version isn't higher than v65280. If the check passes, set that as the title version.
if new_version > 65535:
# Validate that the version isn't higher than 0xFFFF (v65535).
if new_version > 0xFFFF:
raise ValueError("Title version is not valid! Integer version number cannot exceed v65535.")
self.title_version = new_version
else:

View File

@@ -4,14 +4,16 @@
# See https://wiibrew.org/wiki/Title for details about how titles are formatted
import math
from .cert import (CertificateChain as _CertificateChain,
verify_ca_cert as _verify_ca_cert, verify_cert_sig as _verify_cert_sig,
verify_tmd_sig as _verify_tmd_sig, verify_ticket_sig as _verify_ticket_sig)
from .content import ContentRegion as _ContentRegion
from .crypto import encrypt_title_key
from .ticket import Ticket as _Ticket
from .tmd import TMD as _TMD
from .types import ContentType
from .wad import WAD as _WAD
from .crypto import encrypt_title_key
class Title:
@@ -243,7 +245,7 @@ class Title:
# For contents, get their sizes from the content records, because they store the intended sizes of the decrypted
# contents, which are usually different from the encrypted sizes.
for record in self.content.content_records:
if record.content_type == 32769:
if record.content_type == ContentType.SHARED:
if absolute:
title_size += record.content_size
else:
@@ -443,15 +445,15 @@ class Title:
--------
libWiiPy.title.cert
"""
# The entire chain needs to be verified, so start with the CA cert and work our way down. If anything fails
# along the way, future steps don't matter so exit the descending if's and return False.
# I did not understand short-circuiting when I originally wrote this code, and it was 5 nested if statements
# which looked silly. I now understand that this is functionally identical!
try:
if _verify_ca_cert(self.cert_chain.ca_cert) is True:
if _verify_cert_sig(self.cert_chain.ca_cert, self.cert_chain.tmd_cert) is True:
if _verify_tmd_sig(self.cert_chain.tmd_cert, self.tmd) is True:
if _verify_cert_sig(self.cert_chain.ca_cert, self.cert_chain.ticket_cert) is True:
if _verify_ticket_sig(self.cert_chain.ticket_cert, self.ticket) is True:
return True
if _verify_ca_cert(self.cert_chain.ca_cert) and \
_verify_cert_sig(self.cert_chain.ca_cert, self.cert_chain.tmd_cert) and \
_verify_tmd_sig(self.cert_chain.tmd_cert, self.tmd) and \
_verify_cert_sig(self.cert_chain.ca_cert, self.cert_chain.ticket_cert) and \
_verify_ticket_sig(self.cert_chain.ticket_cert, self.ticket):
return True
except ValueError:
raise ValueError("This title's certificate chain is not valid, or does not match the signature type of "
"the TMD/Ticket.")

View File

@@ -10,9 +10,9 @@ import math
import struct
from typing import List
from enum import IntEnum as _IntEnum
from ..types import _ContentRecord
from ..shared import _bitmask
from .util import title_ver_standard_to_dec
from .types import ContentRecord, ContentType, TitleType, Region
from .versions import title_ver_standard_to_dec
class TMD:
@@ -58,7 +58,7 @@ class TMD:
self.num_contents: int = 0 # The number of contents contained in the associated title.
self.boot_index: int = 0 # The content index that contains the bootable executable.
self.minor_version: int = 0 # Minor version (unused typically).
self.content_records: List[_ContentRecord] = []
self.content_records: List[ContentRecord] = []
def load(self, tmd: bytes) -> None:
"""
@@ -151,7 +151,7 @@ class TMD:
tmd_data.seek(0x1E4 + (36 * content))
content_record_hdr = struct.unpack(">LHH4x4s20s", tmd_data.read(36))
self.content_records.append(
_ContentRecord(int(content_record_hdr[0]), int(content_record_hdr[1]),
ContentRecord(int(content_record_hdr[0]), int(content_record_hdr[1]),
int(content_record_hdr[2]), int.from_bytes(content_record_hdr[3]),
binascii.hexlify(content_record_hdr[4])))
@@ -251,7 +251,8 @@ class TMD:
self.minor_version = current_int
# Trim off the first 320 bytes, because we're only looking for the hash of the TMD's body.
# This is a try-except because an OverflowError will be thrown if the number being used to brute-force the
# hash gets too big, as it is only a 16-bit integer. If that happens, then fakesigning has failed.
# hash gets too big, as it is only a 16-bit integer. If that happens, then fakesigning has failed. This
# shouldn't ever realistically happen, though.
try:
test_hash = hashlib.sha1(self.dump()[320:]).hexdigest()
except OverflowError:
@@ -273,8 +274,7 @@ class TMD:
"""
if self.signature != b'\x00' * 256:
return False
test_hash = hashlib.sha1(self.dump()[320:]).hexdigest()
if test_hash[:2] != '00':
if hashlib.sha1(self.dump()[320:]).hexdigest()[:2] != '00':
return False
return True
@@ -292,15 +292,15 @@ class TMD:
The region of the title.
"""
match self.region:
case 0:
case Region.JPN:
return "JPN"
case 1:
case Region.USA:
return "USA"
case 2:
case Region.EUR:
return "EUR"
case 3:
return "None"
case 4:
case Region.WORLD:
return "World"
case Region.KOR:
return "KOR"
case _:
raise ValueError(f"Title contains unknown region \"{self.region}\".")
@@ -318,19 +318,19 @@ class TMD:
The type of the title.
"""
match self.title_id[:8]:
case '00000001':
case TitleType.SYSTEM:
return "System"
case '00010000':
case TitleType.GAME:
return "Game"
case '00010001':
case TitleType.CHANNEL:
return "Channel"
case '00010002':
case TitleType.SYSTEM_CHANNEL:
return "SystemChannel"
case '00010004':
case TitleType.GAME_CHANNEL:
return "GameChannel"
case '00010005':
case TitleType.DLC:
return "DLC"
case '00010008':
case TitleType.HIDDEN_CHANNEL:
return "HiddenChannel"
case _:
return "Unknown"
@@ -360,20 +360,20 @@ class TMD:
# This is the literal index in the list of content that we're going to get.
target_index = current_indices.index(content_index)
match self.content_records[target_index].content_type:
case 1:
case ContentType.NORMAL:
return "Normal"
case 2:
case ContentType.DEVELOPMENT:
return "Development/Unknown"
case 3:
case ContentType.HASH_TREE:
return "Hash Tree"
case 16385:
case ContentType.DLC:
return "DLC"
case 32769:
case ContentType.SHARED:
return "Shared"
case _:
return "Unknown"
def get_content_record(self, record) -> _ContentRecord:
def get_content_record(self, record) -> ContentRecord:
"""
Gets the content record at the specified index.
@@ -390,8 +390,8 @@ class TMD:
if record < self.num_contents:
return self.content_records[record]
else:
raise IndexError("Invalid content record! TMD lists '" + str(self.num_contents - 1) +
"' contents but index was '" + str(record) + "'!")
raise IndexError(f"Invalid content record! TMD lists \"{self.num_contents - 1}\" contents "
f"but index was \"{record}\"!")
def get_content_size(self, absolute=False, dlc=False) -> int:
"""
@@ -415,13 +415,13 @@ class TMD:
"""
title_size = 0
for record in self.content_records:
if record.content_type == 0x8001:
if record.content_type == ContentType.SHARED:
if absolute:
title_size += record.content_size
elif record.content_type == 0x4001:
elif record.content_type == ContentType.DLC:
if dlc:
title_size += record.content_size
elif record.content_type != 3:
elif record.content_type != ContentType.DEVELOPMENT:
title_size += record.content_size
return title_size
@@ -450,10 +450,6 @@ class TMD:
blocks = math.ceil(title_size_bytes / 131072)
return blocks
class AccessFlags(_IntEnum):
AHB = 0
DVD_VIDEO = 1
def get_access_right(self, flag: int) -> bool:
"""
Gets whether the specified access rights flag is enabled or not. This is done by checking the specified bit.
@@ -473,7 +469,7 @@ class TMD:
--------
libWiiPy.title.tmd.TMD.AccessFlags
"""
return bool(self.access_rights & _bitmask(flag))
return bool(self.access_rights & (1 << flag))
def set_title_id(self, title_id) -> None:
"""
@@ -512,10 +508,17 @@ class TMD:
version_converted: int = title_ver_standard_to_dec(new_version, self.title_id)
self.title_version = version_converted
elif type(new_version) is int:
# Validate that the version isn't higher than v65280. If the check passes, set that as the title version,
# then convert to standard form and set that as well.
if new_version > 65535:
# Validate that the version isn't higher than 0xFFFF (v65535).
if new_version > 0xFFFF:
raise ValueError("Title version is not valid! Integer version number cannot exceed v65535.")
self.title_version = new_version
else:
raise TypeError("Title version type is not valid! Type must be either integer or string.")
class AccessFlags(_IntEnum):
"""
Flags set in a TMD's access rights field used to enable specific feature access.
"""
AHB = 0
DVD_VIDEO = 1

View File

@@ -1,11 +1,14 @@
# "types.py" from libWiiPy by NinjaCheetah & Contributors
# "title/types.py" from libWiiPy by NinjaCheetah & Contributors
# https://github.com/NinjaCheetah/libWiiPy
#
# Shared types used across the title module.
from dataclasses import dataclass
from dataclasses import dataclass as _dataclass
from enum import IntEnum as _IntEnum, StrEnum as _StrEnum
@dataclass
class _ContentRecord:
@_dataclass
class ContentRecord:
"""
A content record object that contains the details of a content contained in a title. This information must match
the content stored at the index in the record, or else the content will not decrypt properly, as the hash of the
@@ -29,3 +32,38 @@ class _ContentRecord:
content_type: int # Type of content, possible values of: 0x0001: Normal, 0x4001: DLC, 0x8001: Shared.
content_size: int
content_hash: bytes
class ContentType(_IntEnum):
"""
The type of an individual piece of content.
"""
NORMAL = 0x0001
DEVELOPMENT = 0x0002
HASH_TREE = 0x0003
DLC = 0x4001
SHARED = 0x8001
class TitleType(_StrEnum):
"""
The type of a title.
"""
SYSTEM = "00000001"
GAME = "00010000"
CHANNEL = "00010001"
SYSTEM_CHANNEL = "00010002"
GAME_CHANNEL = "00010004"
DLC = "00010005"
HIDDEN_CHANNEL = "00010008"
class Region(_IntEnum):
"""
The region of a title.
"""
JPN = 0
USA = 1
EUR = 2
WORLD = 3
KOR = 4

View File

@@ -1,10 +1,9 @@
# "title/util.py" from libWiiPy by NinjaCheetah & Contributors
# "title/versions.py" from libWiiPy by NinjaCheetah & Contributors
# https://github.com/NinjaCheetah/libWiiPy
#
# General title-related utilities that don't fit within a specific module.
# Functions for converting the format that a title's version is in.
import math
from ..shared import _wii_menu_versions, _vwii_menu_versions
from ..constants import _WII_MENU_VERSIONS, _VWII_MENU_VERSIONS
def title_ver_dec_to_standard(version: int, title_id: str, vwii: bool = False) -> str:
@@ -27,26 +26,18 @@ def title_ver_dec_to_standard(version: int, title_id: str, vwii: bool = False) -
str
The version of the title, in standard form.
"""
version_out = ""
if title_id == "0000000100000002":
if vwii:
try:
version_out = list(_vwii_menu_versions.keys())[list(_vwii_menu_versions.values()).index(version)]
except ValueError:
version_out = ""
else:
try:
version_out = list(_wii_menu_versions.keys())[list(_wii_menu_versions.values()).index(version)]
except ValueError:
version_out = ""
try:
if vwii:
return list(_VWII_MENU_VERSIONS.keys())[list(_VWII_MENU_VERSIONS.values()).index(version)]
else:
return list(_WII_MENU_VERSIONS.keys())[list(_WII_MENU_VERSIONS.values()).index(version)]
except ValueError:
raise ValueError(f"Unrecognized System Menu version \"{version}\".")
else:
# For most channels, we need to get the floored value of version / 256 for the major version, and the version %
# 256 as the minor version. Minor versions > 9 are intended, as Nintendo themselves frequently used them.
version_upper = math.floor(version / 256)
version_lower = version % 256
version_out = f"{version_upper}.{version_lower}"
return version_out
# Typical titles use a two-byte version format where the upper byte is the major version, and the lower byte is
# the minor version.
return f"{version >> 8}.{version & 0xFF}"
def title_ver_standard_to_dec(version: str, title_id: str) -> int:
@@ -68,13 +59,15 @@ def title_ver_standard_to_dec(version: str, title_id: str) -> int:
int
The version of the title, in decimal form.
"""
version_out = 0
if title_id == "0000000100000002":
raise ValueError("The System Menu's version cannot currently be converted.")
for key in _WII_MENU_VERSIONS.keys():
if version.casefold() == key.casefold():
return _WII_MENU_VERSIONS[key]
for key in _VWII_MENU_VERSIONS.keys():
if version.casefold() == key.casefold():
return _VWII_MENU_VERSIONS[key]
raise ValueError(f"Unrecognized System Menu version \"{version}\".")
else:
version_str_split = version.split(".")
version_upper = int(version_str_split[0]) * 256
version_lower = int(version_str_split[1])
version_out = version_upper + version_lower
return version_out
version_out = (int(version_str_split[0]) << 8) + int(version_str_split[1])
return version_out