2018-07-07 00:38:10 +02:00
|
|
|
# -*- 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
|
|
|
#
|
2018-07-07 00:38:10 +02:00
|
|
|
# This file is part of libray.
|
2019-05-16 10:37:28 +02:00
|
|
|
#
|
2018-07-07 00:38:10 +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
|
|
|
#
|
2018-07-07 00:38:10 +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
|
|
|
#
|
2018-07-07 00:38:10 +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
|
2026-05-21 09:14:20 +02:00
|
|
|
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
|
2018-07-07 00:38:10 +02:00
|
|
|
from Crypto.Cipher import AES
|
|
|
|
|
|
2019-06-07 09:00:03 +02:00
|
|
|
|
2018-07-07 00:38:10 +02:00
|
|
|
try:
|
2026-05-18 16:31:58 +02:00
|
|
|
from libray import core
|
|
|
|
|
from libray import ird
|
|
|
|
|
from libray import sfo
|
2018-07-07 00:38:10 +02:00
|
|
|
except ImportError:
|
2026-05-18 16:31:58 +02:00
|
|
|
import core
|
|
|
|
|
import ird
|
|
|
|
|
import sfo
|
2018-07-07 00:38:10 +02:00
|
|
|
|
|
|
|
|
|
2026-05-21 09:14:20 +02:00
|
|
|
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
|
2026-05-21 09:14:20 +02:00
|
|
|
return bytes(iv)
|
2026-05-19 17:41:17 +02:00
|
|
|
|
|
|
|
|
|
2026-05-21 09:14:20 +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
|
|
|
|
|
|
|
|
|
2018-07-07 00:38:10 +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
|
|
|
"""
|
2022-02-19 00:21:51 +01:00
|
|
|
|
2026-05-18 16:31:58 +02:00
|
|
|
NUM_INFO_BYTES = 4
|
2022-02-19 00:21:51 +01:00
|
|
|
|
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
|
2022-02-19 00:21:51 +01:00
|
|
|
|
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
|
|
|
|
|
}]
|
2022-02-19 00:21:51 +01:00
|
|
|
|
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
|
|
|
|
|
})
|
2018-07-07 00:38:10 +02:00
|
|
|
|
2026-05-18 16:31:58 +02:00
|
|
|
return regions
|
2018-07-07 00:38:10 +02:00
|
|
|
|
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?')
|
2021-06-06 23:09:33 +02:00
|
|
|
|
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))
|
2021-06-06 23:09:33 +02:00
|
|
|
|
2026-05-18 16:31:58 +02:00
|
|
|
# Skip unused bytes
|
|
|
|
|
input_iso.seek(input_iso.tell() + self.NUM_INFO_BYTES)
|
2021-06-06 23:09:33 +02:00
|
|
|
|
2026-05-18 16:31:58 +02:00
|
|
|
self.regions = self.read_regions(input_iso)
|
2021-06-06 23:09:33 +02:00
|
|
|
|
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)
|
2021-06-06 23:09:33 +02:00
|
|
|
|
2026-05-18 16:31:58 +02:00
|
|
|
self.game_id = input_iso.read(16).decode('utf8').strip()
|
2021-06-06 23:09:33 +02:00
|
|
|
|
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
|
2021-06-06 23:09:33 +02:00
|
|
|
|
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)
|
2021-06-06 23:09:33 +02:00
|
|
|
|
2026-05-18 16:31:58 +02:00
|
|
|
if not data:
|
|
|
|
|
break
|
2021-06-06 23:09:33 +02:00
|
|
|
|
2026-05-18 16:31:58 +02:00
|
|
|
# if data == b'PS3LICDA':
|
|
|
|
|
# print(data)
|
2021-06-06 23:09:33 +02:00
|
|
|
|
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
|
2021-06-06 23:09:33 +02:00
|
|
|
|
2026-05-18 16:31:58 +02:00
|
|
|
input_iso.seek((core.SECTOR * counter))
|
2021-06-06 23:09:33 +02:00
|
|
|
|
2026-05-18 16:31:58 +02:00
|
|
|
counter += 1
|
2021-06-06 23:09:33 +02:00
|
|
|
|
2026-05-18 16:31:58 +02:00
|
|
|
game_title = ''
|
2021-06-06 23:09:33 +02:00
|
|
|
|
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'])
|
2021-06-06 23:09:33 +02:00
|
|
|
|
2026-05-18 16:31:58 +02:00
|
|
|
if args.verbose and not args.quiet:
|
|
|
|
|
param.print_info()
|
2018-07-09 07:40:41 +02:00
|
|
|
|
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
|
|
|
|
2026-05-21 09:14:20 +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."""
|
2026-05-21 09:14:20 +02:00
|
|
|
self._process_iso(args, encrypt_mode=False)
|
2021-06-06 23:09:33 +02:00
|
|
|
|
2026-05-21 09:14:20 +02:00
|
|
|
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
|
|
|
|
2026-05-21 09:14:20 +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
|
|
|
|
2026-05-21 09:14:20 +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
|
|
|
|
2026-05-21 09:14:20 +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
|
|
|
|
2026-05-21 09:14:20 +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'
|
2021-06-06 23:09:33 +02:00
|
|
|
|
2026-05-21 09:14:20 +02:00
|
|
|
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
|
|
|
|
2026-05-21 09:14:20 +02:00
|
|
|
total_sectors = self.size // core.SECTOR
|
|
|
|
|
pbar = tqdm(total=total_sectors, file=sys.stdout, disable=args.quiet, leave=True)
|
2018-07-09 07:40:41 +02:00
|
|
|
|
2026-05-19 17:41:17 +02:00
|
|
|
if num_workers > 1:
|
2026-05-21 09:14:20 +02:00
|
|
|
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:
|
2026-05-21 09:14:20 +02:00
|
|
|
self._process_sequential(args, output_name, encrypt_mode, pbar)
|
2021-06-29 22:08:43 +02:00
|
|
|
|
2026-05-21 09:14:20 +02:00
|
|
|
pbar.close()
|
|
|
|
|
core.vprint(f'{"Re-encryption" if encrypt_mode else "Decryption"} complete!', args)
|
2021-06-05 22:03:31 +02:00
|
|
|
|
2026-05-21 09:14:20 +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
|
2026-05-21 09:14:20 +02:00
|
|
|
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'])
|
2026-05-21 09:14:20 +02:00
|
|
|
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
|
2026-05-21 09:14:20 +02:00
|
|
|
out.write(data)
|
2026-05-19 17:41:17 +02:00
|
|
|
pbar.update(1)
|
2026-05-21 09:14:20 +02:00
|
|
|
|
|
|
|
|
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')
|
2021-11-27 22:22:19 +01:00
|
|
|
|
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)
|
2021-11-27 22:22:19 +01:00
|
|
|
|
|
|
|
|
# .ird file given with -k / --ird
|
2026-05-18 16:31:58 +02:00
|
|
|
if args.ird:
|
|
|
|
|
return get_key_from_ird(args.ird)
|
2021-11-27 22:22:19 +01:00
|
|
|
|
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
|
|
|
|
2026-05-21 09:14:20 +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)
|
2026-05-21 09:14:20 +02:00
|
|
|
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:
|
2026-05-21 09:14:20 +02:00
|
|
|
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.')
|
2018-07-07 00:38:10 +02:00
|
|
|
|
2026-05-18 16:31:58 +02:00
|
|
|
keys = c.execute('SELECT name, key FROM games WHERE lower(name) LIKE ? AND size = ?', [
|
2026-05-21 09:14:20 +02:00
|
|
|
'%' + '%'.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)
|
2026-05-21 09:14:20 +02:00
|
|
|
cancel = threading.Event()
|
|
|
|
|
crc_done = threading.Event()
|
2026-05-18 16:31:58 +02:00
|
|
|
if args.checksum_timeout > 0:
|
2026-05-21 09:14:20 +02:00
|
|
|
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)
|
2026-05-21 09:14:20 +02:00
|
|
|
cancel.set()
|
|
|
|
|
|
|
|
|
|
crc_thread = Thread(target=timeout, daemon=True)
|
2026-05-18 16:31:58 +02:00
|
|
|
crc_thread.start()
|
|
|
|
|
|
2026-05-21 09:14:20 +02:00
|
|
|
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
|
|
|
|
|
|
2026-05-21 09:14:20 +02:00
|
|
|
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:
|
2026-05-21 09:14:20 +02:00
|
|
|
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]
|
|
|
|
|
|
2026-05-21 09:14:20 +02:00
|
|
|
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)
|