9 Commits

Author SHA1 Message Date
c604c195d2 Correct line endings when dumping setting.txt 2024-10-09 20:38:00 -04:00
0c2e13f18a Remove leftover debugging print 2024-10-08 14:06:09 -04:00
7fed039fdc Added methods to get a content index from a CID and add content from a Title() 2024-09-13 14:56:37 -04:00
0d306076a2 Added methods to content module to remove contents by index or CID 2024-09-11 11:13:01 -04:00
a1773b9a02 Improved adding new content to title, fixed minor bugs
Dumping a title now properly updates the "number of contents" field in the TMD, so you're able to add more content than there was previously, and that new content will be added correctly.
2024-09-08 13:15:52 -04:00
7c2f0fb21f Fix getting a title's type 2024-09-05 11:07:14 -04:00
0edd4fa6bb Update how TMD regions are handled 2024-09-04 14:28:13 -04:00
e163d34f0b Allow for calculating title size with and without shared content 2024-09-04 14:23:24 -04:00
9fb0fdbc17 Added setting.txt parser, moved some modules under a new "nand" subpackage 2024-08-14 01:26:46 -04:00
13 changed files with 370 additions and 80 deletions

View File

@@ -6,6 +6,7 @@
:maxdepth: 4
libWiiPy.archive
libWiiPy.nand
libWiiPy.title
```

View File

@@ -0,0 +1,27 @@
# libWiiPy.nand package
## Submodules
### libWiiPy.nand.emunand module
```{eval-rst}
.. automodule:: libWiiPy.nand.emunand
:members:
:undoc-members:
:show-inheritance:
```
### libWiiPy.nand.setting module
```{eval-rst}
.. automodule:: libWiiPy.nand.setting
:members:
:undoc-members:
:show-inheritance:
```
### libWiiPy.nand.sys module
```{eval-rst}
.. automodule:: libWiiPy.nand.sys
:members:
:undoc-members:
:show-inheritance:
```

View File

@@ -26,14 +26,6 @@
:show-inheritance:
```
### libWiiPy.title.emunand module
```{eval-rst}
.. automodule:: libWiiPy.title.emunand
:members:
:undoc-members:
:show-inheritance:
```
### libWiipy.title.iospatcher module
```{eval-rst}
.. automodule:: libWiiPy.title.iospatcher
@@ -50,14 +42,6 @@
:show-inheritance:
```
### libWiiPy.title.sys module
```{eval-rst}
.. automodule:: libWiiPy.title.sys
:members:
:undoc-members:
:show-inheritance:
```
### libWiiPy.title.ticket module
```{eval-rst}
.. automodule:: libWiiPy.title.ticket

View File

@@ -1,6 +1,6 @@
[project]
name = "libWiiPy"
version = "0.5.0"
version = "0.5.1"
authors = [
{ name="NinjaCheetah", email="ninjacheetah@ncxprogramming.com" },
{ name="Lillian Skinner", email="lillian@randommeaninglesscharacters.com" }

View File

@@ -3,7 +3,8 @@
#
# These are the essential submodules from libWiiPy that you'd probably want imported by default.
__all__ = ["archive", "title"]
__all__ = ["archive", "nand", "title"]
from . import archive
from . import nand
from . import title

View File

@@ -0,0 +1,6 @@
# "nand/__init__.py" from libWiiPy by NinjaCheetah & Contributors
# https://github.com/NinjaCheetah/libWiiPy
from .emunand import *
from .setting import *
from .sys import *

View File

@@ -1,4 +1,4 @@
# "title/emunand.py" from libWiiPy by NinjaCheetah & Contributors
# "nand/emunand.py" from libWiiPy by NinjaCheetah & Contributors
# https://github.com/NinjaCheetah/libWiiPy
#
# Code for handling setting up and modifying a Wii EmuNAND.
@@ -6,8 +6,8 @@
import os
import pathlib
import shutil
from .title import Title
from .content import SharedContentMap as _SharedContentMap
from ..title.title import Title
from ..title.content import SharedContentMap as _SharedContentMap
from .sys import UidSys as _UidSys

View File

@@ -0,0 +1,134 @@
# "nand/setting.py" from libWiiPy by NinjaCheetah & Contributors
# https://github.com/NinjaCheetah/libWiiPy
#
# See https://wiibrew.org/wiki//title/00000001/00000002/data/setting.txt for information about setting.txt.
import io
from ..shared import _pad_bytes
_key = 0x73B5DBFA
class SettingTxt:
"""
A SettingTxt object that allows for decrypting and then parsing a setting.txt file from the Wii.
Attributes
----------
area : str
The region of the System Menu this file matches with.
model : str
The model of the console, usually RVL-001 or RVL-101.
dvd : int
Unknown, might have to do with indicating support for scrapped DVD playback capabilities.
mpch : str
Unknown, generally accepted value is "0x7FFE".
code : str
Unknown code, may match with manufacturer code in serial number?
serial_number : str
Serial number of the console.
video : str
Video mode, either NTSC or PAL.
game : str
Another region code, possibly set by the hidden region select channel.
"""
def __init__(self):
self.area: str = ""
self.model: str = ""
self.dvd: int = 0
self.mpch: str = "" # What does this mean, Movie Player Channel? It's also a hex string, it seems.
self.code: str = ""
self.serial_number: str = ""
self.video: str = ""
self.game: str = ""
def load(self, setting_txt: bytes) -> None:
"""
Loads the raw data of an encrypted setting.txt file and decrypts it to parse its arguments
Parameters
----------
setting_txt : bytes
The data of an encrypted setting.txt file.
"""
with io.BytesIO(setting_txt) as setting_data:
global _key # I still don't actually know what *kind* of encryption this is.
setting_txt_dec: [int] = []
for i in range(0, 256):
setting_txt_dec.append(int.from_bytes(setting_data.read(1)) ^ (_key & 0xff))
_key = (_key << 1) | (_key >> 31)
setting_txt_dec = bytes(setting_txt_dec)
try:
setting_str = setting_txt_dec.decode('utf-8')
except UnicodeDecodeError:
last_newline_pos = setting_txt_dec.rfind(b'\n') # This makes sure we don't try to decode any garbage data.
setting_str = setting_txt_dec[:last_newline_pos + 1].decode('utf-8')
self.load_decrypted(setting_str)
def load_decrypted(self, setting_txt: str) -> None:
"""
Loads the raw data of a decrypted setting.txt file and parses its arguments
Parameters
----------
setting_txt : str
The data of a decrypted setting.txt file.
"""
setting_dict = {}
# Iterate over every key in the file to create a dictionary.
for line in setting_txt.splitlines():
line = line.strip()
if line is not None:
key, value = line.split('=', 1)
setting_dict[key.strip()] = value.strip()
# Load the values from the dictionary into the object.
self.area = setting_dict["AREA"]
self.model = setting_dict["MODEL"]
self.dvd = int(setting_dict["DVD"])
self.mpch = setting_dict["MPCH"]
self.code = setting_dict["CODE"]
self.serial_number = setting_dict["SERNO"]
self.video = setting_dict["VIDEO"]
self.game = setting_dict["GAME"]
def dump(self) -> bytes:
"""
Dumps the SettingTxt object back into an encrypted bytes that the Wii can load.
Returns
-------
bytes
The setting.txt file as encrypted bytes.
"""
setting_str = self.dump_decrypted()
setting_txt_dec = setting_str.encode()
global _key
# This could probably be made more efficient somehow.
setting_txt_enc: [int] = []
with io.BytesIO(setting_txt_dec) as setting_data:
for i in range(0, len(setting_txt_dec)):
setting_txt_enc.append(int.from_bytes(setting_data.read(1)) ^ (_key & 0xff))
_key = (_key << 1) | (_key >> 31)
setting_txt_enc = _pad_bytes(bytes(setting_txt_enc), 256)
return setting_txt_enc
def dump_decrypted(self) -> str:
"""
Dumps the SettingTxt object into a decrypted string.
Returns
-------
str
The setting.txt file as decrypted text.
"""
# Write the keys back into a text file that can then be manually edited or re-encrypted.
setting_txt = ""
setting_txt += f"AREA={self.area}\r\n"
setting_txt += f"MODEL={self.model}\r\n"
setting_txt += f"DVD={self.dvd}\r\n"
setting_txt += f"MPCH={self.mpch}\r\n"
setting_txt += f"CODE={self.code}\r\n"
setting_txt += f"SERNO={self.serial_number}\r\n"
setting_txt += f"VIDEO={self.video}\r\n"
setting_txt += f"GAME={self.game}\r\n"
return setting_txt

View File

@@ -1,4 +1,4 @@
# "title/sys.py" from libWiiPy by NinjaCheetah & Contributors
# "nand/sys.py" from libWiiPy by NinjaCheetah & Contributors
# https://github.com/NinjaCheetah/libWiiPy
#
# See https://wiibrew.org/wiki//sys/uid.sys for information about uid.sys.
@@ -28,7 +28,7 @@ class _UidSysEntry:
class UidSys:
"""
A UidSys object to parse and edit the uid.sys file stored in /sys/ on the Wii's NAND. This file is used to track all
the titles installed on the console.
the titles that have been launched on a console.
Attributes
----------

View File

@@ -3,10 +3,8 @@
from .content import *
from .crypto import *
from .emunand import *
from .iospatcher import *
from .nus import *
from .sys import *
from .ticket import *
from .title import *
from .tmd import *

View File

@@ -8,11 +8,19 @@ import io
import hashlib
from typing import List
from dataclasses import dataclass as _dataclass
from enum import IntEnum as _IntEnum
from ..types import _ContentRecord
from ..shared import _pad_bytes, _align_value
from .crypto import decrypt_content, encrypt_content
class ContentType(_IntEnum):
NORMAL = 1
HASH_TREE = 3
DLC = 16385
SHARED = 32769
class ContentRegion:
"""
A ContentRegion object to parse the continuous content region of a WAD. Allows for retrieving content from the
@@ -150,16 +158,11 @@ class ContentRegion:
bytes
The encrypted content listed in the content record.
"""
# Get a list of the current Content IDs, so we can make sure the target one exists.
content_ids = []
for record in self.content_records:
content_ids.append(record.content_id)
if cid not in content_ids:
raise ValueError("You are trying to get a content with Content ID " + str(cid) + ", but no content with "
"that ID exists!")
# Get the content index associated with the CID we now know exists.
target_index = content_ids.index(cid)
content_index = self.content_records[target_index].index
try:
content_index = self.get_index_from_cid(cid)
except ValueError:
raise ValueError(f"You are trying to get a content with Content ID {cid}, "
f"but no content with that ID exists!")
content_enc = self.get_enc_content_by_index(content_index)
return content_enc
@@ -238,16 +241,11 @@ class ContentRegion:
bytes
The decrypted content listed in the content record.
"""
# Get a list of the current Content IDs, so we can make sure the target one exists.
content_ids = []
for record in self.content_records:
content_ids.append(record.content_id)
if cid not in content_ids:
raise ValueError("You are trying to get a content with Content ID " + str(cid) + ", but no content with "
"that ID exists!")
# Get the content index associated with the CID we now know exists.
target_index = content_ids.index(cid)
content_index = self.content_records[target_index].index
try:
content_index = self.get_index_from_cid(cid)
except ValueError:
raise ValueError(f"You are trying to get a content with Content ID {cid}, "
f"but no content with that ID exists!")
content_dec = self.get_content_by_index(content_index, title_key, skip_hash)
return content_dec
@@ -273,6 +271,32 @@ class ContentRegion:
dec_contents.append(self.get_content_by_index(content, title_key, skip_hash))
return dec_contents
def get_index_from_cid(self, cid: int) -> int:
"""
Gets the content index of a content by its Content ID. The returned index is the value tied to each content and
used as the IV for encryption, rather than the literal index in the array of content, because sometimes the
contents end up out of order in a WAD while still retaining the original indices.
Parameters
----------
cid : int
The Content ID to get the index of.
Returns
-------
int
The content index.
"""
# Get a list of the current Content IDs, so we can make sure the target one exists.
content_ids = []
for record in self.content_records:
content_ids.append(record.content_id)
if cid not in content_ids:
raise ValueError("The specified Content ID does not exist!")
literal_index = content_ids.index(cid)
target_index = self.content_records[literal_index].index
return target_index
def add_enc_content(self, enc_content: bytes, cid: int, index: int, content_type: int, content_size: int,
content_hash: bytes) -> None:
"""
@@ -286,7 +310,7 @@ class ContentRegion:
cid : int
The Content ID to assign the new content in the content record.
index : int
The index to place the new content at.
The index used when encrypting the new content.
content_type : int
The type of the new content.
content_size : int
@@ -304,10 +328,11 @@ class ContentRegion:
self.content_list.append(enc_content)
self.content_records.append(_ContentRecord(cid, index, content_type, content_size, content_hash))
def add_content(self, dec_content: bytes, cid: int, index: int, content_type: int, title_key: bytes) -> None:
def add_content(self, dec_content: bytes, cid: int, content_type: int, title_key: bytes) -> None:
"""
Adds a new decrypted content to the ContentRegion, and adds the provided Content ID, index, content type,
content size, and content hash to a new record in the ContentRecord list.
Adds a new decrypted content to the end of the ContentRegion, and adds the provided Content ID, content type,
content size, and content hash to a new record in the ContentRecord list. The index will be automatically
assigned by incrementing the current highest index in the records.
This first gets the content hash and size from the provided data, and then encrypts the content with the
provided Title Key before adding it to the ContentRegion.
@@ -318,13 +343,16 @@ class ContentRegion:
The new decrypted content to add.
cid : int
The Content ID to assign the new content in the content record.
index : int
The index to place the new content at.
content_type : int
The type of the new content.
title_key : bytes
The Title Key that matches the other content in the ContentRegion.
"""
# Find the current highest content index and increment it for this content.
content_indices = []
for record in self.content_records:
content_indices.append(record.index)
index = max(content_indices) + 1
content_size = len(dec_content)
content_hash = str.encode(hashlib.sha1(dec_content).hexdigest())
enc_content = encrypt_content(dec_content, title_key, index)
@@ -486,6 +514,49 @@ class ContentRegion:
enc_content = encrypt_content(dec_content, title_key, index)
self.content_list[target_index] = enc_content
def remove_content_by_index(self, index: int) -> None:
"""
Removes the content at the specified index from the ContentRegion and content records.
This will allow gaps to be left in content indices, however this should not cause any issues.
Parameters
----------
index : int
The index of the content you want to remove.
"""
# Get a list of the current content indices, so we can make sure the target one exists. Doing it this way
# ensures we can find the target, even if the highest content index is greater than the highest literal index.
current_indices = []
for record in self.content_records:
current_indices.append(record.index)
if index not in current_indices:
raise ValueError("You are trying to remove the content at index " + str(index) + ", but no content with "
"that index currently exists!")
# This is the literal index in the list of content/content records that we're going to change.
target_index = current_indices.index(index)
# Delete the target index from both the content list and content records.
self.content_list.pop(target_index)
self.content_records.pop(target_index)
def remove_content_by_cid(self, cid: int) -> None:
"""
Removes the content with the specified Content ID from the ContentRegion and content records.
This will allow gaps to be left in content indices, however this should not cause any issues.
Parameters
----------
cid : int
The Content ID of the content you want to remove.
"""
try:
content_index = self.get_index_from_cid(cid)
except ValueError:
raise ValueError(f"You are trying to remove content with Content ID {cid}, "
f"but no content with that ID exists!")
self.remove_content_by_index(content_index)
@_dataclass
class _SharedContentRecord:

View File

@@ -76,7 +76,9 @@ class Title:
if self.tmd.title_id == "0000000100000001":
self.wad.wad_type = "ib"
# Dump the TMD and set it in the WAD.
# This requires updating the content records and number of contents in the TMD first.
self.tmd.content_records = self.content.content_records
self.tmd.num_contents = len(self.content.content_records)
self.wad.set_tmd_data(self.tmd.dump())
# Dump the Ticket and set it in the WAD.
self.wad.set_ticket_data(self.ticket.dump())
@@ -190,9 +192,15 @@ class Title:
dec_content = self.content.get_content_by_cid(cid, self.ticket.get_title_key(), skip_hash)
return dec_content
def get_title_size(self) -> int:
def get_title_size(self, absolute=False) -> int:
"""
Gets the installed size of the title, including the TMD and Ticket, in bytes.
Gets the installed size of the title, including the TMD and Ticket, in bytes. The "absolute" option determines
whether shared content sizes should be included in the total size or not. This option defaults to False.
Parameters
----------
absolute : bool, optional
Whether shared contents should be included in the total size or not. Defaults to False.
Returns
-------
@@ -207,30 +215,90 @@ 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 absolute:
title_size += record.content_size
else:
title_size += record.content_size
return title_size
def get_title_size_blocks(self) -> int:
def get_title_size_blocks(self, absolute=False) -> int:
"""
Gets the installed size of the title, including the TMD and Ticket, in the Wii's displayed "blocks" format.
Gets the installed size of the title, including the TMD and Ticket, in the Wii's displayed "blocks" format. The
"absolute" option determines whether shared content sizes should be included in the total size or not. This
option defaults to False.
1 Wii block is equal to 128KiB, and if any amount of a block is used, the entire block is considered used.
Parameters
----------
absolute : bool, optional
Whether shared contents should be included in the total size or not. Defaults to False.
Returns
-------
int
The installed size of the title, in blocks.
"""
title_size_bytes = self.get_title_size()
title_size_bytes = self.get_title_size(absolute)
blocks = math.ceil(title_size_bytes / 131072)
return blocks
def add_enc_content(self, enc_content: bytes, cid: int, index: int, content_type: int, content_size: int,
content_hash: bytes) -> None:
"""
Adds a new encrypted content to the ContentRegion, and adds the provided Content ID, index, content type,
content size, and content hash to a new record in the ContentRecord list.
Parameters
----------
enc_content : bytes
The new encrypted content to add.
cid : int
The Content ID to assign the new content in the content record.
index : int
The index used when encrypting the new content.
content_type : int
The type of the new content.
content_size : int
The size of the new encrypted content when decrypted.
content_hash : bytes
The hash of the new encrypted content when decrypted.
"""
# Add the encrypted content.
self.content.add_enc_content(enc_content, cid, index, content_type, content_size, content_hash)
# Update the TMD to match.
self.tmd.content_records = self.content.content_records
def add_content(self, dec_content: bytes, cid: int, content_type: int) -> None:
"""
Adds a new decrypted content to the end of the ContentRegion, and adds the provided Content ID, content type,
content size, and content hash to a new record in the ContentRecord list. The index will be automatically
assigned by incrementing the current highest index in the records.
This first gets the content hash and size from the provided data, and then encrypts the content with the
Title Key before adding it to the ContentRegion.
Parameters
----------
dec_content : bytes
The new decrypted content to add.
cid : int
The Content ID to assign the new content in the content record.
content_type : int
The type of the new content.
"""
# Add the decrypted content.
self.content.add_content(dec_content, cid, content_type, self.ticket.get_title_key())
# Update the TMD to match.
self.tmd.content_records = self.content.content_records
def set_enc_content(self, enc_content: bytes, index: int, content_size: int, content_hash: bytes, cid: int = None,
content_type: int = None) -> None:
"""
Sets the content at the provided content index to the provided new encrypted content. The provided hash and
content size are set in the corresponding content record. A new Content ID or content type can also be
specified, but if it isn't than the current values are preserved.
specified, but if it isn't then the current values are preserved.
This uses the content index, which is the value tied to each content and used as the IV for encryption, rather
than the literal index in the array of content, because sometimes the contents end up out of order in a WAD
@@ -262,7 +330,7 @@ class Title:
"""
Sets the content at the provided content index to the provided new decrypted content. The hash and content size
of this content will be generated and then set in the corresponding content record. A new Content ID or content
type can also be specified, but if it isn't than the current values are preserved.
type can also be specified, but if it isn't then the current values are preserved.
This also updates the content records in the TMD after the content is set.

View File

@@ -8,7 +8,7 @@ import binascii
import hashlib
import struct
from typing import List
from enum import IntEnum
from enum import IntEnum as _IntEnum
from ..types import _ContentRecord
from ..shared import _bitmask
from .util import title_ver_dec_to_standard, title_ver_standard_to_dec
@@ -37,7 +37,7 @@ class TMD:
self.blob_header: bytes = b''
self.signature_type: int = 0
self.signature: bytes = b''
self.issuer: bytes = b'' # Follows the format "Root-CA%08x-CP%08x"
self.signature_issuer: str = "" # Follows the format "Root-CA%08x-CP%08x"
self.tmd_version: int = 0 # This seems to always be 0 no matter what?
self.ca_crl_version: int = 0 # Certificate Authority Certificate Revocation List version
self.signer_crl_version: int = 0 # Certificate Policy Certificate Revocation List version
@@ -45,7 +45,7 @@ class TMD:
self.ios_tid: str = "" # The Title ID of the IOS version the associated title runs on.
self.ios_version: int = 0 # The IOS version the associated title runs on.
self.title_id: str = "" # The Title ID of the associated title.
self.title_type: str = "" # The type of the associated title.
self.title_type: bytes = b'' # The type of the associated title. Should always be 00000001 in a Wii TMD.
self.group_id: int = 0 # The ID of the publisher of the associated title.
self.region: int = 0 # The ID of the region of the associated title.
self.ratings: bytes = b'' # The parental controls rating of the associated title.
@@ -82,7 +82,7 @@ class TMD:
self.signature = tmd_data.read(256)
# Signing certificate issuer.
tmd_data.seek(0x140)
self.issuer = tmd_data.read(64)
self.signature_issuer = str(tmd_data.read(64).decode())
# TMD version, seems to usually be 0, but I've seen references to other numbers.
tmd_data.seek(0x180)
self.tmd_version = int.from_bytes(tmd_data.read(1))
@@ -107,11 +107,10 @@ class TMD:
title_id_bin = tmd_data.read(8)
title_id_hex = binascii.hexlify(title_id_bin)
self.title_id = str(title_id_hex.decode())
# Type of content.
# Type of the title. This is an internal property used to show if this title is for the ill-fated
# NetCard (0), or the Wii (1), and is therefore always 1 for Wii TMDs.
tmd_data.seek(0x194)
content_type_bin = tmd_data.read(4)
content_type_hex = binascii.hexlify(content_type_bin)
self.title_type = str(content_type_hex.decode())
self.title_type = tmd_data.read(4)
# Publisher of the title.
tmd_data.seek(0x198)
self.group_id = int.from_bytes(tmd_data.read(2))
@@ -175,7 +174,7 @@ class TMD:
# Padding to 64 bytes.
tmd_data += b'\x00' * 60
# Signing certificate issuer.
tmd_data += self.issuer
tmd_data += str.encode(self.signature_issuer)
# TMD version.
tmd_data += int.to_bytes(self.tmd_version, 1)
# Certificate Authority CRL version.
@@ -188,8 +187,8 @@ class TMD:
tmd_data += binascii.unhexlify(self.ios_tid)
# Title's Title ID.
tmd_data += binascii.unhexlify(self.title_id)
# Content type.
tmd_data += binascii.unhexlify(self.title_type)
# Title type.
tmd_data += self.title_type
# Group ID.
tmd_data += int.to_bytes(self.group_id, 2)
# 2 bytes of zero for reasons.
@@ -280,10 +279,11 @@ class TMD:
def get_title_region(self) -> str:
"""
Gets the region of the TMD's associated title.
Gets the system region specified in the TMD. This is not necessarily the true region of the title, but is the
hardware region that this title is designed and allowed to be run on.
Can be one of several possible values:
'Japan', 'North America', 'Europe', 'World', or 'Korea'.
'JPN', 'USA', 'EUR', 'None', or 'KOR'.
Returns
-------
@@ -292,19 +292,19 @@ class TMD:
"""
match self.region:
case 0:
return "Japan"
return "JPN"
case 1:
return "North America"
return "USA"
case 2:
return "Europe"
return "EUR"
case 3:
return "World"
return "None"
case 4:
return "Korea"
return "KOR"
def get_title_type(self) -> str:
"""
Gets the type of the TMD's associated title.
Gets the type of the title this TMD describes. The title_type field is not related to these types.
Can be one of several possible values:
'System', 'Game', 'Channel', 'SystemChannel', 'GameChannel', or 'HiddenChannel'
@@ -314,7 +314,7 @@ class TMD:
str
The type of the title.
"""
match self.title_type:
match self.title_id[:8]:
case '00000001':
return "System"
case '00010000':
@@ -390,7 +390,7 @@ class TMD:
raise IndexError("Invalid content record! TMD lists '" + str(self.num_contents - 1) +
"' contents but index was '" + str(record) + "'!")
class AccessFlags(IntEnum):
class AccessFlags(_IntEnum):
AHB = 0
DVD_VIDEO = 1