Files
Mrsuss60 ed73550c85 gpu, scripts: resolve startup stalls via async pipeline warmup and cached pre-launch
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.
2026-11-02 17:47:31 +00:00

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')