mirror of
https://github.com/Mrsuss60/bloodborne_pc_windows_port.git
synced 2026-11-07 05:56:20 -10:00
Eliminate multi-minute boot delays across pre-window launch and initial black screen presentation: - Vulkan (vk_pipeline_serialization): Dispatch cached graphics pipelines asynchronously to worker threads via pending_graphics during WarmUp, removing the synchronous driver compilation stall on the render thread before the first frame. - Vulkan (vk_shader_rebuild): Protect RebuildDirectory on Windows with Vectored Exception Handling to safely bypass incompatible shaders, and persist ShaderBinaryVersion in profile.bin on failure to stop repeating broken rebuilds every launch. - Vulkan (vk_pipeline_import): Cap startup shader recipe merges to a 5 ms budget, deferring remaining imports to per-frame polls. - Host Runtime (probe.c): Defer bbgpu_rebuild_shader_caches() until after bbgpu_init() so the SDL window and presenter open immediately. - Pre-launch Scripts (prepare, link_libc, link_modules): Add mtime checks against source binaries to reuse existing boot images, and remove the redundant 28,000-file traversal in prepare.py. - Platform (nvidia_profile): Add a single-run guard to BbNvidiaProfile::Apply() to prevent redundant NVAPI driver queries.
306 lines
16 KiB
Python
306 lines
16 KiB
Python
#!/usr/bin/env python3
|
|
"""Inspect an already plaintext PS4 SELF; prepare a native entry-point probe.
|
|
|
|
No decryption, patches to source files, third-party modules or Python dependencies.
|
|
Format references are linked in README.md. All offsets are checked before use.
|
|
"""
|
|
import argparse
|
|
import base64
|
|
import collections
|
|
import hashlib
|
|
import json
|
|
from pathlib import Path
|
|
import struct
|
|
import sys
|
|
|
|
|
|
def span(data, offset, size):
|
|
if offset < 0 or size < 0 or offset + size > len(data):
|
|
raise ValueError(f"out of bounds: {offset:#x}+{size:#x} / {len(data):#x}")
|
|
return data[offset:offset + size]
|
|
|
|
|
|
def unpack(fmt, data, offset):
|
|
return struct.unpack(fmt, span(data, offset, struct.calcsize(fmt)))
|
|
|
|
|
|
def parse_self(data, name="executable binary"):
|
|
if len(data) < 32:
|
|
raise ValueError(f"{name} is empty or truncated ({len(data)} bytes, minimum 32 bytes required)")
|
|
magic = span(data, 0, 4)
|
|
raw_elf = magic == b'\x7fELF' # already-unwrapped ELF (e.g. some dumpers)
|
|
if not raw_elf and magic != b'O\x15=\x1d':
|
|
raise ValueError(f"{name}: expected PS4 SELF header (magic bytes O\\x15=\\x1d) or a raw ELF")
|
|
if raw_elf:
|
|
count, base = 0, 0
|
|
else:
|
|
count, = unpack('<H', data, 24)
|
|
base = 32 + count * 32
|
|
header = unpack('<16sHHIQQQIHHHHHH', data, base)
|
|
if header[0][:7] != b'\x7fELF\x02\x01\x01' or header[2] != 62:
|
|
raise ValueError(f"{name}: expected little-endian x86-64 ELF")
|
|
if header[9] != 56 or not 0 < header[10] < 256:
|
|
raise ValueError(f"{name}: unsupported program headers")
|
|
ph = [dict(zip(('type', 'flags', 'offset', 'vaddr', 'paddr', 'filesz', 'memsz', 'align'),
|
|
unpack('<IIQQQQQQ', data, base + header[5] + i * 56)))
|
|
for i in range(header[10])]
|
|
end = max(p['offset'] + p['filesz'] for p in ph)
|
|
if end > 512 * 1024 * 1024:
|
|
raise ValueError(f"{name}: probe image exceeds 512 MiB limit")
|
|
if raw_elf:
|
|
span(data, 0, end) # every segment must lie inside the file
|
|
return bytearray(data), header, ph, [], []
|
|
elf = bytearray(end)
|
|
header_end = header[5] + header[10] * 56
|
|
elf[:header_end] = span(data, base, header_end)
|
|
covered = [(0, header_end)]
|
|
segments = []
|
|
for i in range(count):
|
|
flags, off, size, mem = unpack('<QQQQ', data, 32 + i * 32)
|
|
segments.append(dict(flags=hex(flags), offset=off, size=size))
|
|
if not flags & 0x800:
|
|
continue
|
|
if flags & 10:
|
|
raise ValueError(f"{name}: encrypted/compressed SELF segment is unsupported")
|
|
index = (flags >> 20) & 4095
|
|
if index >= len(ph):
|
|
raise ValueError(f"{name}: invalid segment index")
|
|
p = ph[index]
|
|
if size != p['filesz'] or size != mem:
|
|
raise ValueError(f"{name}: unsupported blocked segment layout")
|
|
elf[p['offset']:p['offset'] + size] = span(data, off, size)
|
|
covered.append((p['offset'], p['offset'] + size))
|
|
missing = []
|
|
for i, p in enumerate(ph):
|
|
if p['filesz'] and not any(a <= p['offset'] and p['offset'] + p['filesz'] <= z
|
|
for a, z in covered):
|
|
missing.append(i)
|
|
if p['type'] in (1, 2, 0x61000000, 0x61000010):
|
|
raise ValueError(f"{name}: required segment {i} is unavailable")
|
|
return elf, header, ph, segments, missing
|
|
|
|
|
|
def sfo(data):
|
|
magic, version, keys, values, count = unpack('<4sIIII', data, 0)
|
|
if magic != b'\0PSF':
|
|
raise ValueError('bad SFO signature')
|
|
result = {}
|
|
for i in range(count):
|
|
key, fmt, size, capacity, off = unpack('<HHIII', data, 20 + i * 16)
|
|
k = data[keys + key:data.index(0, keys + key)].decode()
|
|
v = span(data, values + off, size)
|
|
result[k] = struct.unpack('<I', v)[0] if fmt == 0x404 else v.rstrip(b'\0').decode('utf8', 'replace')
|
|
return result
|
|
|
|
|
|
def nid(name):
|
|
salt = bytes.fromhex('518d64a635ded8c1e6b039b1c3e55230')
|
|
digest = hashlib.sha1(name.encode('ascii') + salt).digest()[:8][::-1]
|
|
return base64.b64encode(digest).decode().rstrip('=').replace('/', '-')
|
|
|
|
|
|
def inspect_libc(path):
|
|
"""Prove that this dump's _init_env is exactly RET; never assume it."""
|
|
if not path.is_file():
|
|
raise FileNotFoundError(f"Missing required module '{path.name}' at '{path}'. Bloodborne requires sce_module/libc.prx from the game dump.")
|
|
size = path.stat().st_size
|
|
if size < 32:
|
|
raise ValueError(f"Module '{path.name}' at '{path}' is invalid or empty ({size} bytes, minimum 32 bytes required). Please ensure your game files are fully decrypted and dumped.")
|
|
source = path.read_bytes()
|
|
elf, header, ph, _, missing = parse_self(source, name=str(path.name))
|
|
dynamic = next(p for p in ph if p['type'] == 2)
|
|
tags = dict(unpack('<QQ', elf, pos) for pos in
|
|
range(dynamic['offset'], dynamic['offset'] + dynamic['filesz'], 16))
|
|
lib = next(p for p in ph if p['type'] == 0x61000000)
|
|
blob = span(elf, lib['offset'], lib['filesz'])
|
|
strings = span(blob, tags[0x61000035], tags[0x61000037])
|
|
syms = span(blob, tags[0x61000039], tags[0x6100003f])
|
|
evidence = {'sha256': hashlib.sha256(source).hexdigest(), 'init_env_is_ret': False}
|
|
for pos in range(0, len(syms), 24):
|
|
name, info, other, section, value, size = unpack('<IBBHQQ', syms, pos)
|
|
span(strings, name, 1)
|
|
symbol = strings[name:strings.index(0, name)].decode('ascii')
|
|
key = symbol.split('#')[0]
|
|
if key not in (nid('_init_env'), nid('_ZNSt8ios_base4InitC1Ev')) or not section or info & 15 != 2:
|
|
continue
|
|
p = next(p for p in ph if p['type'] == 1 and p['flags'] & 1
|
|
and p['vaddr'] <= value < p['vaddr'] + p['filesz'])
|
|
code = span(elf, p['offset'] + value - p['vaddr'], size)
|
|
if key == nid('_init_env'):
|
|
evidence.update(symbol=symbol, address=hex(value), size=size, bytes=code.hex(),
|
|
init_env_is_ret=code == b'\xc3')
|
|
else:
|
|
evidence['ios_base_init'] = dict(symbol=symbol, address=hex(value), size=size,
|
|
code_sha256=hashlib.sha256(code).hexdigest(),
|
|
name='_ZNSt8ios_base4InitC1Ev')
|
|
return evidence
|
|
|
|
|
|
def find_eboot(game):
|
|
for candidate in ('eboot.bin', 'eboot.elf'):
|
|
p = Path(game) / candidate
|
|
if p.is_file():
|
|
return p
|
|
return None
|
|
|
|
|
|
def prepare(game, out):
|
|
eboot_path = find_eboot(game)
|
|
if not eboot_path:
|
|
raise FileNotFoundError(f"Missing eboot.bin or eboot.elf in '{game}'. Please check your game directory.")
|
|
boot_bin = out / 'boot.bin'
|
|
analysis_json = out / 'analysis.json'
|
|
eboot_mtime = eboot_path.stat().st_mtime
|
|
script_mtime = Path(__file__).stat().st_mtime
|
|
if boot_bin.is_file() and analysis_json.is_file():
|
|
boot_mtime = boot_bin.stat().st_mtime
|
|
if boot_mtime >= eboot_mtime and boot_mtime >= script_mtime:
|
|
try:
|
|
report = json.loads(analysis_json.read_text(encoding='utf-8', errors='replace'))
|
|
title = report['sfo'].get('TITLE', 'Bloodborne')
|
|
safe_title = title.encode(sys.stdout.encoding or 'utf-8', 'replace').decode(sys.stdout.encoding or 'utf-8', 'replace')
|
|
print(f"{safe_title} | entry={report.get('entry')} | image={report.get('image_bytes', 0):,} bytes")
|
|
print(f"{report.get('import_count', len(report.get('imports', [])))} imported symbols; {sum(report.get('relocation_counts', {}).values()):,} relocations; {len(report.get('needed', []))} required modules")
|
|
print(f"Unavailable non-loadable metadata headers: {report.get('unavailable_metadata_headers', [])}; not a byte-exact ELF reconstruction")
|
|
print(f"Output: {out.resolve()}")
|
|
libc_evidence = report.get('libc_evidence', {})
|
|
print(f"libc _init_env verified RET: {libc_evidence.get('init_env_is_ret', True)}")
|
|
return
|
|
except Exception:
|
|
pass
|
|
source = eboot_path.read_bytes()
|
|
if len(source) == 0:
|
|
raise ValueError(f"{eboot_path.name} at '{eboot_path}' is completely empty (0 bytes). Check your game dump or extraction.")
|
|
if len(source) < 32:
|
|
raise ValueError(f"{eboot_path.name} at '{eboot_path}' is truncated ({len(source)} bytes).")
|
|
elf, header, ph, segments, missing = parse_self(source, name=eboot_path.name)
|
|
loads = [p for p in ph if p['type'] in (1, 0x61000010)]
|
|
def mapped(addr, size=8):
|
|
return any(p['vaddr'] <= addr and addr + size <= p['vaddr'] + p['memsz'] for p in loads)
|
|
size = max(p['vaddr'] + p['memsz'] for p in loads)
|
|
if size > 512 * 1024 * 1024 or not mapped(header[4], 1):
|
|
raise ValueError('invalid memory image')
|
|
image = bytearray(size)
|
|
for p in loads:
|
|
if p['filesz'] > p['memsz']:
|
|
raise ValueError('segment file size exceeds memory size')
|
|
image[p['vaddr']:p['vaddr'] + p['filesz']] = span(elf, p['offset'], p['filesz'])
|
|
dp = next(p for p in ph if p['type'] == 2)
|
|
dyn = []
|
|
for pos in range(dp['offset'], dp['offset'] + dp['filesz'], 16):
|
|
tag, value = unpack('<QQ', elf, pos)
|
|
if tag == 0:
|
|
break
|
|
dyn.append((tag, value))
|
|
tags = dict(dyn)
|
|
lib = next(p for p in ph if p['type'] == 0x61000000)
|
|
blob = span(elf, lib['offset'], lib['filesz'])
|
|
strings = span(blob, tags[0x61000035], tags[0x61000037])
|
|
def string(off):
|
|
span(strings, off, 1)
|
|
return strings[off:strings.index(0, off)].decode('ascii')
|
|
syms = span(blob, tags[0x61000039], tags[0x6100003f])
|
|
if len(syms) % 24:
|
|
raise ValueError('bad symbol table size')
|
|
symbols = []
|
|
for pos in range(0, len(syms), 24):
|
|
name, info, other, shndx, value, sz = unpack('<IBBHQQ', syms, pos)
|
|
symbols.append(dict(name=string(name), type=info & 15, section=shndx, value=value, size=sz))
|
|
relocs, counts, imports = [], collections.Counter(), {}
|
|
for offset_tag, size_tag in ((0x61000029, 0x6100002d), (0x6100002f, 0x61000031)):
|
|
table = span(blob, tags[offset_tag], tags[size_tag])
|
|
if len(table) % 24:
|
|
raise ValueError('bad relocation table size')
|
|
for pos in range(0, len(table), 24):
|
|
target, info, addend = unpack('<QQq', table, pos)
|
|
kind, sym = info & 0xffffffff, info >> 32
|
|
counts[kind] += 1
|
|
if not mapped(target):
|
|
raise ValueError(f'relocation outside mapped segments: {target:#x}')
|
|
if kind == 8:
|
|
relocs.append((target, 0, addend, 0)) # base-relative
|
|
elif kind in (1, 6, 7):
|
|
symbol = symbols[sym]
|
|
if symbol['section']:
|
|
if symbol['section'] == 0xfff1:
|
|
struct.pack_into('<Q', image, target, symbol['value'] + addend)
|
|
else:
|
|
relocs.append((target, 0, symbol['value'] + addend, 0))
|
|
else:
|
|
if sym not in imports:
|
|
imports[sym] = len(imports)
|
|
kind = 2 if symbol['type'] == 1 else 1
|
|
if (kind == 1 and addend) or not 0 <= addend < 4096:
|
|
raise ValueError('unsupported import addend')
|
|
relocs.append((target, kind, imports[sym], addend))
|
|
else:
|
|
raise ValueError(f'unsupported relocation {kind}')
|
|
names = [symbols[i]['name'] for i in imports]
|
|
candidates = ('_init_env', 'atexit', 'exit', '_exit', '__cxa_atexit', '__cxa_finalize',
|
|
'__cxa_guard_acquire', '__cxa_guard_release', '__cxa_guard_abort',
|
|
'__stack_chk_guard', '__stack_chk_fail', 'malloc', 'free',
|
|
'memcpy', 'memmove', 'memset', 'memcmp', 'strlen', 'printf',
|
|
'_ZNSt8ios_base4InitC1Ev',
|
|
'sceKernelLoadStartModule', 'sceVideoOutOpen', 'sceKernelCreateSema', 'sceKernelWaitSema', 'sceKernelPollSema',
|
|
'sceKernelSignalSema', 'sceKernelCancelSema', 'sceKernelDeleteSema',
|
|
'sceAppContentInitialize', 'sceAppContentAppParamGetInt', 'sceAppContentGetAddcontInfoList',
|
|
'pthread_cond_init', 'sceSysmoduleLoadModule', 'pthread_mutexattr_init', 'gettimeofday',
|
|
'scePthreadMutexattrInit', 'scePthreadMutexattrSettype', 'scePthreadMutexattrDestroy',
|
|
'scePthreadMutexInit', 'scePthreadMutexLock', 'scePthreadMutexTrylock',
|
|
'scePthreadMutexUnlock', 'scePthreadMutexDestroy', 'scePthreadRwlockInit',
|
|
'scePthreadRwlockDestroy', 'scePthreadRwlockRdlock', 'scePthreadRwlockWrlock',
|
|
'scePthreadRwlockTryrdlock', 'scePthreadRwlockTrywrlock', 'scePthreadRwlockUnlock',
|
|
'scePthreadRwlockTimedrdlock', 'scePthreadRwlockTimedwrlock',
|
|
'sceKernelGetDirectMemorySize', 'sceKernelAllocateDirectMemory',
|
|
'sceKernelMapDirectMemory', 'sceKernelReleaseDirectMemory', 'sceKernelMunmap')
|
|
known = {nid(name): name for name in candidates}
|
|
libc_evidence = inspect_libc(game / 'sce_module/libc.prx')
|
|
out.mkdir(parents=True, exist_ok=True)
|
|
# A deliberately small format, consumed by probe.c; no host struct packing.
|
|
with (out / 'boot.bin').open('wb') as f:
|
|
f.write(struct.pack('<8sQQQQQQ', b'BBPROBE2', size, header[4], len(loads), len(relocs), len(names),
|
|
int(libc_evidence['init_env_is_ret'])))
|
|
for p in loads:
|
|
f.write(struct.pack('<QQQ', p['vaddr'], p['memsz'], p['flags']))
|
|
for name in names:
|
|
encoded = name.encode()
|
|
if len(encoded) >= 128:
|
|
raise ValueError('import name too long')
|
|
f.write(encoded.ljust(128, b'\0'))
|
|
for relocation in relocs:
|
|
f.write(struct.pack('<QQqq', *relocation))
|
|
f.write(image)
|
|
(out / 'eboot.elf').write_bytes(elf)
|
|
(out / 'entry.bin').write_bytes(image[:1024])
|
|
resources = collections.Counter()
|
|
total_bytes = 0
|
|
report = dict(source_sha256=hashlib.sha256(source).hexdigest(), source_bytes=len(source),
|
|
sfo=sfo((game / 'sce_sys/param.sfo').read_bytes()), entry=hex(header[4]),
|
|
image_bytes=size, program_headers=ph, self_segments=segments,
|
|
unavailable_metadata_headers=missing, needed=[string(v) for t, v in dyn if t == 1],
|
|
relocation_counts=dict(counts), import_count=len(names), imports=names,
|
|
import_name_hints={name: known[name.split('#')[0]] for name in names if name.split('#')[0] in known},
|
|
libc_evidence=libc_evidence,
|
|
bundled_modules=sorted(p.name for p in (game / 'sce_module').iterdir()),
|
|
resources=dict(resources), resource_bytes=total_bytes,
|
|
status='Prepared only; execution and Vulkan are tested separately.')
|
|
(out / 'analysis.json').write_text(json.dumps(report, ensure_ascii=True, indent=2) + '\n', encoding='utf-8')
|
|
title = report['sfo'].get('TITLE', 'Bloodborne')
|
|
safe_title = title.encode(sys.stdout.encoding or 'utf-8', 'replace').decode(sys.stdout.encoding or 'utf-8', 'replace')
|
|
print(f"{safe_title} | entry={header[4]:#x} | image={size:,} bytes")
|
|
print(f"{len(names)} imported symbols; {sum(counts.values()):,} relocations; {len(report['needed'])} required modules")
|
|
print(f"Unavailable non-loadable metadata headers: {missing}; not a byte-exact ELF reconstruction")
|
|
print(f"Output: {out.resolve()}")
|
|
print(f"libc _init_env verified RET: {libc_evidence['init_env_is_ret']}")
|
|
|
|
|
|
if __name__ == '__main__':
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('game', type=Path)
|
|
parser.add_argument('--out', type=Path, default=Path(__file__).resolve().parent.parent / 'out')
|
|
args = parser.parse_args()
|
|
try:
|
|
prepare(args.game, args.out)
|
|
except (ValueError, OSError, StopIteration, KeyError, IndexError) as error:
|
|
parser.exit(1, f'prepare failed: {error}\n')
|