Improve WAD handling, fixes IOS WADs made with other tools not extracting

The way content sizes are handled has been adjusted to allow IOS WADs (which have their content structured a bit differently) made via other tools to be extracted.
Writing out WADs has also been changed so that the content size in the header now matches the output of older tools.
This commit is contained in:
Campbell 2024-07-02 18:29:15 +10:00
parent 9bfb44771e
commit 1f731bbc81
Signed by: NinjaCheetah
GPG Key ID: 670C282B3291D63D
4 changed files with 34 additions and 17 deletions

View File

@ -18,14 +18,14 @@ In libWiiPy, a TMD is represented by a `TMD()` object, which is part of the `tmd
## Ticket
<project:#libWiiPy.title.ticket>
A **Ticket** primarily contains the encrypted Title Key for a title, as well as the information required to decrypt that key. They come in two forms: common tickets, which are freely available from the Nintendo Update Servers, 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).
A **Ticket** primarily contains the encrypted Title Key for a title, as well as the information required to decrypt that key. They come in two forms: common tickets, which are freely available from the Nintendo Update Servers (NUS), and personalized tickets, which are issued to your console specifically by the Wii Shop Channel (or at least they were before it closed, excluding the free titles still available).
In libWiiPy, a Ticket is represented by a `Ticket()` object, which is part of the `ticket` module in the `title` subpackage, and is imported automatically.
## Content
<project:#libWiiPy.title.content>
**Contents** are the files in a title that contain the actual data, whether that be the main executable or resources required by it. They're usually stored encrypted in a WAD file or on the Nintendo Update Servers, 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.
**Contents** are the files in a title that contain the actual data, whether that be the main executable or resources required by it. They're usually stored encrypted in a WAD file or on the NUS, until they are decrypted during installation to a console. The Title Key stored in the Ticket is required to decrypt the contents of a title. Each content has a matching record with its index and Content ID, as well as the SHA-1 hash of its decrypted data. These records are stored in the TMD.
In libWiiPy, contents are represented by a `ContentRegion()` object, which is part of the `content` module in the `title` subpackge, and is imported automatically. A content record is represented by its own `ContentRecord()` object, which is a private class designed to only be used by other modules.

View File

@ -4,10 +4,10 @@
# See https://wiibrew.org/wiki/Title for details about how titles are formatted
import io
import sys
import hashlib
from typing import List
from ..types import _ContentRecord
from ..shared import _pad_bytes
from .crypto import decrypt_content, encrypt_content
@ -43,9 +43,9 @@ class ContentRegion:
A list of ContentRecord objects detailing all contents contained in the region.
"""
self.content_records = content_records
# Get the total size of the content region.
self.content_region_size = len(content_region)
with io.BytesIO(content_region) as content_region_data:
# Get the total size of the content region.
self.content_region_size = sys.getsizeof(content_region_data)
self.num_contents = len(self.content_records)
# Calculate the offsets of each content in the content region.
# Content is aligned to 16 bytes, however a new content won't start until the next multiple of 64 bytes.
@ -56,7 +56,7 @@ class ContentRegion:
start_offset += 64 - (content.content_size % 64)
self.content_start_offsets.append(start_offset)
# Build a list of all the encrypted content data.
for content in range(len(self.content_start_offsets)):
for content in range(self.num_contents):
# Seek to the start of the content based on the list of offsets.
content_region_data.seek(self.content_start_offsets[content])
# Calculate the number of bytes we need to read by adding bytes up the nearest multiple of 16 if needed.
@ -68,7 +68,7 @@ class ContentRegion:
content_enc = content_region_data.read(bytes_to_read)
self.content_list.append(content_enc)
def dump(self) -> bytes:
def dump(self) -> tuple[bytes, int]:
"""
Takes the list of contents and assembles them back into one content region. Returns this content region as a
bytes object and sets the raw content region variable to this result, then calls load() again to make sure the
@ -77,19 +77,28 @@ class ContentRegion:
Returns
-------
bytes
The full WAD file as bytes.
The full ContentRegion as bytes, including padding between content.
int
The size of the ContentRegion, including padding.
"""
content_region_data = b''
for content in self.content_list:
# If this isn't the first content, pad the whole region to 64 bytes before the next one.
if content_region_data is not b'':
content_region_data = _pad_bytes(content_region_data, 64)
# Calculate padding after this content before the next one.
padding_bytes = 0
if (len(content) % 64) != 0:
padding_bytes = 64 - (len(content) % 64)
if (len(content) % 16) != 0:
padding_bytes = 16 - (len(content) % 16)
# Write content data, then the padding afterward if necessary.
content_region_data += content
if padding_bytes > 0:
content_region_data += b'\x00' * padding_bytes
return content_region_data
# Calculate the size of the whole content region.
content_region_size = 0
for record in range(len(self.content_records)):
content_region_size += self.content_records[record].content_size
return content_region_data, content_region_size
def get_enc_content_by_index(self, index: int) -> bytes:
"""
@ -173,7 +182,7 @@ class ContentRegion:
# Compare the hash and throw a ValueError if the hash doesn't match.
if content_dec_hash != content_record_hash:
raise ValueError("Content hash did not match the expected hash in its record! The incorrect Title Key may "
"have been used!.\n"
"have been used!\n"
"Expected hash is: {}\n".format(content_record_hash) +
"Actual hash is: {}".format(content_dec_hash))
return content_dec

View File

@ -78,7 +78,8 @@ class Title:
# Dump the Ticket and set it in the WAD.
self.wad.set_ticket_data(self.ticket.dump())
# Dump the ContentRegion and set it in the WAD.
self.wad.set_content_data(self.content.dump())
content_data, content_size = self.content.dump()
self.wad.set_content_data(content_data, content_size)
return self.wad.dump()
def load_tmd(self, tmd: bytes) -> None:

View File

@ -91,9 +91,11 @@ class WAD:
# WAD TMD size.
wad_data.seek(0x14)
self.wad_tmd_size = int(binascii.hexlify(wad_data.read(4)), 16)
# WAD content size.
# WAD content size. This needs to be rounded now, because with some titles (primarily IOS?), there can be
# extra bytes past the listed end of the content that is needed for decryption.
wad_data.seek(0x18)
self.wad_content_size = int(binascii.hexlify(wad_data.read(4)), 16)
self.wad_content_size = _align_value(self.wad_content_size, 16)
# Time/build stamp for the title contained in the WAD.
wad_data.seek(0x1c)
self.wad_meta_size = int(binascii.hexlify(wad_data.read(4)), 16)
@ -309,7 +311,7 @@ class WAD:
# Calculate the size of the new Ticket data.
self.wad_tik_size = len(tik_data)
def set_content_data(self, content_data) -> None:
def set_content_data(self, content_data, size: int = None) -> None:
"""
Sets the content data of the WAD. Also calculates the new size.
@ -317,10 +319,15 @@ class WAD:
----------
content_data : bytes
The new content data.
size : int, option
The size of the new content data.
"""
self.wad_content_data = content_data
# Calculate the size of the new content data.
self.wad_content_size = len(content_data)
# Calculate the size of the new content data, if one wasn't supplied.
if size is None:
self.wad_content_size = len(content_data)
else:
self.wad_content_size = size
def set_meta_data(self, meta_data) -> None:
"""