LibRay/libray/iso.py

422 lines
16 KiB
Python
Raw Normal View History

# -*- coding: utf8 -*-
# libray - Libre Blu-Ray PS3 ISO Tool
2026-05-18 16:31:58 +02:00
# Copyright © 2018 - 2024 Nichlas Severinsen
2019-05-16 10:37:28 +02:00
#
# This file is part of libray.
2019-05-16 10:37:28 +02:00
#
# libray is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
2019-05-16 10:37:28 +02:00
#
# libray is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
2019-05-16 10:37:28 +02:00
#
# You should have received a copy of the GNU General Public License
# along with libray. If not, see <https://www.gnu.org/licenses/>.
2018-07-07 12:13:11 +02:00
2026-05-18 16:31:58 +02:00
import os
2018-07-07 12:13:11 +02:00
import sys
2021-06-05 22:03:31 +02:00
import sqlite3
2026-05-19 17:41:17 +02:00
import concurrent.futures
import threading
from collections import deque
2026-05-19 17:41:17 +02:00
from threading import Thread
from importlib import resources
2018-07-07 12:13:11 +02:00
from tqdm import tqdm
from Crypto.Cipher import AES
2019-06-07 09:00:03 +02:00
try:
2026-05-18 16:31:58 +02:00
from libray import core
from libray import ird
from libray import sfo
except ImportError:
2026-05-18 16:31:58 +02:00
import core
import ird
import sfo
def _make_iv(sector_number):
"""Build a 16-byte IV from a sector number (little-endian)."""
2026-05-19 17:41:17 +02:00
iv = bytearray(16)
num = sector_number
for j in range(16):
iv[15 - j] = num & 0xFF
num >>= 8
return bytes(iv)
2026-05-19 17:41:17 +02:00
def _process_sector_chunk_mp(args):
"""Multiprocessing worker: (de/en)crypt a contiguous run of sectors.
Picklable, module-level function for use with ProcessPoolExecutor. Takes a
single ``bytes`` blob of one or more whole sectors and returns the processed
blob, so only one (large) object is pickled per task instead of many small
ones this is what makes the multiprocessing worthwhile.
"""
disc_key, sector_blob, sector_start_number, encrypt_mode = args
out = bytearray()
for offset in range(0, len(sector_blob), core.SECTOR):
sector = sector_blob[offset:offset + core.SECTOR]
iv = _make_iv(sector_start_number + offset // core.SECTOR)
cipher = AES.new(disc_key, AES.MODE_CBC, iv)
out += cipher.encrypt(sector) if encrypt_mode else cipher.decrypt(sector)
return bytes(out)
2026-05-19 17:41:17 +02:00
class ISO:
2026-05-18 16:31:58 +02:00
"""Class for handling PS3 .iso files.
Attributes:
size: Size of .iso in bytes
number_of_regions: Number of regions in the .iso
regions: List with info of every region
game_id: PS3 game id
ird: IRD object (see ird.py)
disc_key: data1 from .ird, encrypted
2019-11-03 14:49:24 +01:00
"""
2026-05-18 16:31:58 +02:00
NUM_INFO_BYTES = 4
2026-05-18 16:31:58 +02:00
def read_regions(self, input_iso):
"""List with info dict (start, end, whether it's encrypted) for every region.
2019-11-03 14:49:24 +01:00
2026-05-18 16:31:58 +02:00
Basically, every other (odd numbered) region is encrypted.
"""
2019-11-03 14:49:24 +01:00
2026-05-18 16:31:58 +02:00
# The first region is always unencrypted
2019-11-03 14:49:24 +01:00
2026-05-18 16:31:58 +02:00
encrypted = False
2026-05-18 16:31:58 +02:00
regions = [{
'start': core.to_int(input_iso.read(self.NUM_INFO_BYTES)) * core.SECTOR, # Should always be 0?
'end': core.to_int(input_iso.read(self.NUM_INFO_BYTES)) * core.SECTOR + core.SECTOR,
'enc': encrypted
}]
2026-05-18 16:31:58 +02:00
# We'll read 4 bytes until we hit a non-size (<=0)
2019-11-03 14:49:24 +01:00
2026-05-18 16:31:58 +02:00
while True:
2019-11-03 14:49:24 +01:00
2026-05-18 16:31:58 +02:00
encrypted = not encrypted
2019-11-03 14:49:24 +01:00
2026-05-18 16:31:58 +02:00
end = core.to_int(input_iso.read(self.NUM_INFO_BYTES)) * core.SECTOR
2019-06-07 09:00:03 +02:00
2026-05-18 16:31:58 +02:00
if not end:
break
2019-11-03 14:49:24 +01:00
2026-05-18 16:31:58 +02:00
regions.append({
'start': regions[-1]['end'],
'end': end + core.SECTOR - (core.SECTOR if encrypted else 0),
'enc': encrypted
})
2026-05-18 16:31:58 +02:00
return regions
2026-05-18 16:31:58 +02:00
def __init__(self, args):
"""ISO constructor using args from argparse."""
2019-11-03 14:49:24 +01:00
2026-05-18 16:31:58 +02:00
self.size = core.size(args.iso)
2018-07-07 19:03:47 +02:00
2026-05-18 16:31:58 +02:00
if not self.size:
core.error('looks like ISO file/mount is empty?')
2026-05-18 16:31:58 +02:00
with open(args.iso, 'rb') as input_iso:
# Get number of unencrypted regions
self.number_of_unencrypted_regions = core.to_int(input_iso.read(self.NUM_INFO_BYTES))
2026-05-18 16:31:58 +02:00
# Skip unused bytes
input_iso.seek(input_iso.tell() + self.NUM_INFO_BYTES)
2026-05-18 16:31:58 +02:00
self.regions = self.read_regions(input_iso)
2026-05-18 16:31:58 +02:00
# Seek to the start of sector 2, '+ 16' skips a section containing some 'playstation'
input_iso.seek(core.SECTOR + 16)
2026-05-18 16:31:58 +02:00
self.game_id = input_iso.read(16).decode('utf8').strip()
2026-05-18 16:31:58 +02:00
# Find PARAM.SFO
2021-06-29 22:08:43 +02:00
2026-05-18 16:31:58 +02:00
core.vprint('Searching for PARAM.SFO', args)
2021-06-29 22:08:43 +02:00
2026-05-18 16:31:58 +02:00
input_iso.seek(0)
counter = 1
found_param = False
2026-05-18 16:31:58 +02:00
while True:
2021-06-29 22:08:43 +02:00
2026-05-18 16:31:58 +02:00
data = input_iso.read(8)
2026-05-18 16:31:58 +02:00
if not data:
break
2026-05-18 16:31:58 +02:00
# if data == b'PS3LICDA':
# print(data)
2026-05-18 16:31:58 +02:00
if data[0:4] == b'\x00\x50\x53\x46':
found_param = True
2021-06-29 22:08:43 +02:00
2026-05-18 16:31:58 +02:00
# input_iso.seek(input_iso.tell() - 8)
# param = sfo.SFO(input_iso)
# print(param['TITLE'])
# print(param['TITLE_ID'])
break
2026-05-18 16:31:58 +02:00
input_iso.seek((core.SECTOR * counter))
2026-05-18 16:31:58 +02:00
counter += 1
2026-05-18 16:31:58 +02:00
game_title = ''
2026-05-18 16:31:58 +02:00
if found_param:
input_iso.seek(input_iso.tell() - 8)
try:
param = sfo.SFO(input_iso)
core.vprint('PARAM.SFO found', args)
2021-06-29 22:08:43 +02:00
2026-05-18 16:31:58 +02:00
game_title = core.multiman_title(param['TITLE'])
2026-05-18 16:31:58 +02:00
if args.verbose and not args.quiet:
param.print_info()
2026-05-18 16:31:58 +02:00
# Set output to multiman style
if not args.output:
args.output = f'{game_title} [{param["TITLE_ID"]}].iso'
2021-06-29 22:08:43 +02:00
except (UnicodeDecodeError, KeyError, IndexError, ValueError):
2026-05-18 16:31:58 +02:00
core.warning('Failed reading SFO', args)
2021-06-05 22:03:31 +02:00
2026-05-18 16:31:58 +02:00
self.disc_key = self.get_key_from_args(game_title, args)
if args.verbose and not args.quiet:
self.print_info()
2021-06-05 22:03:31 +02:00
2026-05-19 17:41:17 +02:00
def decrypt(self, args):
"""Decrypt self using args from argparse."""
self._process_iso(args, encrypt_mode=False)
def encrypt(self, args):
"""Encrypt self using args from argparse."""
self._process_iso(args, encrypt_mode=True)
2021-06-29 22:08:43 +02:00
def _process_iso(self, args, encrypt_mode):
"""Shared driver for decrypt/encrypt. ``encrypt_mode`` selects direction."""
2021-06-29 22:08:43 +02:00
core.vprint(f'{"Re-encrypting" if encrypt_mode else "Decrypting"} with disc key: {self.disc_key.hex()}', args)
2021-06-29 22:08:43 +02:00
num_workers = args.threads if args.threads and args.threads > 0 else (os.cpu_count() or 1)
2021-06-29 22:08:43 +02:00
if args.output:
output_name = args.output
else:
output_name = f'{self.game_id}_e.iso' if encrypt_mode else f'{self.game_id}.iso'
core.vprint(f'{"Re-encrypted" if encrypt_mode else "Decrypted"} .iso is output to: {output_name}', args)
2021-06-05 22:03:31 +02:00
total_sectors = self.size // core.SECTOR
pbar = tqdm(total=total_sectors, file=sys.stdout, disable=args.quiet, leave=True)
2026-05-19 17:41:17 +02:00
if num_workers > 1:
core.vprint(f'Using {num_workers} processes for parallel {"re-encryption" if encrypt_mode else "decryption"}', args)
self._process_parallel(args, output_name, encrypt_mode, num_workers, pbar)
2026-05-19 17:41:17 +02:00
else:
self._process_sequential(args, output_name, encrypt_mode, pbar)
2021-06-29 22:08:43 +02:00
pbar.close()
core.vprint(f'{"Re-encryption" if encrypt_mode else "Decryption"} complete!', args)
2021-06-05 22:03:31 +02:00
def _process_sequential(self, args, output_name, encrypt_mode, pbar):
"""Single-process path: read, optionally (de/en)crypt, and write per sector."""
with open(args.iso, 'rb') as input_iso, open(output_name, 'wb') as output_file:
2026-05-19 17:41:17 +02:00
for region in self.regions:
region_sectors = (region['end'] - region['start']) // core.SECTOR
base_sector = region['start'] // core.SECTOR
input_iso.seek(region['start'])
for i in range(region_sectors):
data = input_iso.read(core.SECTOR)
if not data:
core.warning('Trying to read past the end of the file', args)
break
if region['enc']:
cipher = AES.new(self.disc_key, AES.MODE_CBC, _make_iv(base_sector + i))
data = cipher.encrypt(data) if encrypt_mode else cipher.decrypt(data)
output_file.write(data)
pbar.update(1)
def _process_parallel(self, args, output_name, encrypt_mode, num_workers, pbar):
"""Multi-process path: one process pool for the whole operation.
The output file is pre-sized so every region can be written to its
absolute offset; this also clears any stale file from a previous run.
"""
# Pre-create the output at its final size and write to absolute offsets.
total_bytes = self.regions[-1]['end']
with open(output_name, 'wb') as f:
f.truncate(total_bytes)
with concurrent.futures.ProcessPoolExecutor(max_workers=num_workers) as executor, \
open(args.iso, 'rb') as input_iso, \
open(output_name, 'r+b') as out:
for region in self.regions:
if region['enc']:
self._process_region_parallel(input_iso, out, region, executor, num_workers, encrypt_mode, pbar)
else:
# Unencrypted region — copy straight through to its offset.
region_sectors = (region['end'] - region['start']) // core.SECTOR
2026-05-19 17:41:17 +02:00
input_iso.seek(region['start'])
out.seek(region['start'])
2026-05-19 17:41:17 +02:00
for _ in range(region_sectors):
data = input_iso.read(core.SECTOR)
if not data:
core.warning('Trying to read past the end of the file', args)
break
out.write(data)
2026-05-19 17:41:17 +02:00
pbar.update(1)
def _process_region_parallel(self, input_iso, out, region, executor, num_workers, encrypt_mode, pbar):
"""(De/en)crypt one encrypted region across worker processes.
Reads the region in bounded-size chunks and keeps only a small window of
chunks in flight, so peak memory stays bounded even for huge ISOs.
Results are written back to their absolute offsets as they complete.
"""
num_sectors = (region['end'] - region['start']) // core.SECTOR
base_sector = region['start'] // core.SECTOR
# Sectors per task: large enough to amortise IPC, small enough to keep
# every worker busy and bound peak memory.
sectors_per_chunk = max(1, min(512, (num_sectors // (num_workers * 4)) or 1))
max_in_flight = num_workers * 4
input_iso.seek(region['start'])
next_sector = base_sector
sectors_left = num_sectors
pending = deque()
def submit_more():
nonlocal next_sector, sectors_left
while sectors_left > 0 and len(pending) < max_in_flight:
count = min(sectors_per_chunk, sectors_left)
blob = input_iso.read(count * core.SECTOR)
future = executor.submit(_process_sector_chunk_mp,
(self.disc_key, blob, next_sector, encrypt_mode))
pending.append((next_sector, count, future))
next_sector += count
sectors_left -= count
submit_more()
while pending:
start_sector, count, future = pending.popleft()
out.seek(start_sector * core.SECTOR)
out.write(future.result())
pbar.update(count)
submit_more()
2026-05-18 16:31:58 +02:00
def get_key_from_args(self, game_title, args):
# key provided with -d / --decryption-key
if args.decryption_key:
return core.to_bytes(args.decryption_key)
def get_key_from_ird(i):
self.ird = ird.IRD(i)
if self.ird.region_count != len(self.regions):
core.error(
f'Corrupt ISO or error in IRD. Expected {self.ird.region_count} regions, found {len(self.regions)} regions')
2026-05-18 16:31:58 +02:00
if self.regions[-1]['start'] > self.size:
core.error(
f'Corrupt ISO or error in IRD. Expected filesize larger than {self.regions[-1]["start"]/1024**3:.2f} GiB, actual size is {self.size/1024**3:.2f} GiB')
cipher = AES.new(core.ISO_SECRET, AES.MODE_CBC, core.ISO_IV)
return cipher.encrypt(self.ird.data1)
# .ird file given with -k / --ird
2026-05-18 16:31:58 +02:00
if args.ird:
return get_key_from_ird(args.ird)
2026-05-18 16:31:58 +02:00
# No key or .ird specified. Let's first check if keys.db is packaged with this release
core.vprint('Checking for bundled redump keys', args)
2021-06-02 20:25:49 +02:00
db_path = resources.files(__name__).joinpath('data', 'keys.db')
db = sqlite3.connect(str(db_path))
2026-05-18 16:31:58 +02:00
c = db.cursor()
# UPDATE: 2024 - New database now has game/title ids. See if we have that.
core.vprint('Searching using TITLE_ID', args)
try:
keys = c.execute('SELECT name, key FROM games WHERE title_id = ?', [self.game_id.replace('-', '')]).fetchall()
except sqlite3.OperationalError:
core.error('keys.db not found or invalid. Build it with: python3 tools/keys2db.py')
2026-05-18 16:31:58 +02:00
if len(keys) == 1:
core.vprint(f'Found potential redump key: "{keys[0][0]}"', args)
return keys[0][1]
# Then check if there's only one game with this exact size
core.vprint('Trying to find redump key based on size', args)
keys = c.execute('SELECT name, key FROM games WHERE size = ?', [str(self.size)]).fetchall()
if len(keys) == 1:
core.vprint(f'Found potential redump key: "{keys[0][0]}"', args)
return keys[0][1]
# If not, see if we can filter it out based on name and size
core.vprint('Trying to find redump key based on size, game title, and country', args)
if not game_title:
core.error('Could not determine game title from PARAM.SFO. Specify a decryption key with -d or provide an IRD file with -k.')
try:
country = core.serial_country(self.game_id)
except ValueError:
core.error(f'Unknown country code in game ID "{self.game_id}". Specify a decryption key with -d or provide an IRD file with -k.')
2026-05-18 16:31:58 +02:00
keys = c.execute('SELECT name, key FROM games WHERE lower(name) LIKE ? AND size = ?', [
'%' + '%'.join(game_title.lower().split(' ')) + '%' + country.lower() + '%', str(self.size)]).fetchall()
2026-05-18 16:31:58 +02:00
if keys:
core.vprint(f'Found potential redump key: "{keys[0][0]}"', args)
return keys[0][1]
# since checksums can take a while to calculate, bail here unless the
# user has specifically indicated they want to try the CRC32 fallback
if not args.checksum:
core.error('could not find disc key')
# Okay, searching has failed us, but maaaybe the checksum works?
core.vprint('Trying to find redump key based on CRC32', args)
cancel = threading.Event()
crc_done = threading.Event()
2026-05-18 16:31:58 +02:00
if args.checksum_timeout > 0:
def timeout():
# Abort the CRC32 calculation if it hasn't finished in time.
if not crc_done.wait(timeout=float(args.checksum_timeout)):
2026-05-18 16:31:58 +02:00
core.vprint(f'could not calculate CRC32 before {args.checksum_timeout}-second timeout', args)
cancel.set()
crc_thread = Thread(target=timeout, daemon=True)
2026-05-18 16:31:58 +02:00
crc_thread.start()
calculated_crc = core.crc32(args.iso, cancel)
crc_done.set()
if calculated_crc is None:
2026-05-18 16:31:58 +02:00
raise TimeoutError
keys = c.execute('SELECT name, key FROM games WHERE crc32=?', [calculated_crc.lower()]).fetchall()
2026-05-18 16:31:58 +02:00
if len(keys) == 1:
core.vprint(f'Found potential redump key: "{keys[0][0]}" (CRC32={calculated_crc.lower()})', args)
2026-05-18 16:31:58 +02:00
return keys[0][1]
core.error('could not find disc key')
2026-05-18 16:31:58 +02:00
def print_info(self):
# TODO: This could probably have been a __str__? Who cares?
"""Print some info about the ISO."""
print(f'Game ID: {self.game_id}')
print(f'Key: {self.disc_key.hex()}')
print(f'Info from ISO:')
print(f'Unencrypted regions: {self.number_of_unencrypted_regions}')
for i, region in enumerate(self.regions):
print(i, region, region['start'] // core.SECTOR, region['end'] // core.SECTOR)