Files
Supermedo 0af6cd4b3b Windows port
Builds and runs bbport natively on 64-bit Windows 10 (1903+) and 11 with the MSYS2 CLANG64
toolchain, from the same sources as the Linux build.

- Loader and runtime: guest TLS through a TEB TLS slot, the PS4 address range kept as
  placeholders for section views, vectored exception handling with bb_setjmp/bb_longjmp,
  high-resolution sleeps, Windows file, time, thread and memory back ends (src/compat_win.*).
- Renderer: builds against libc++ and winpthreads; avoids GetThreadId system calls on the hot
  path (cached recorder/bind-helper state), portable shader cache directory.
- run.py replaces run.sh; patches.py applies the 1.09 patches only to a 1.09 game; mods use
  junctions/hard links when symlinks are not allowed.
- Windows launcher (Tkinter) with every setting, 13 languages, frame cap option, FSR 4 asset
  download, desktop shortcut and a Buy Me a Coffee link; packaged with PyInstaller as
  Bloodborne.exe so players need no Python.
- Bloodborne-style icon for the launcher, bb-probe.exe and bb-gpu-capabilities.exe.
- README: Windows section, build notes (packaging/windows/README.md) and credits.
2026-10-06 19:54:58 +02:00

183 lines
8.5 KiB
Python

#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-2.0-or-later
"""Windows counterpart of run.sh: prepares the game image and starts bb-probe.exe.
Same environment variables as run.sh (BB_GAME_DIR, BB_DATA_DIR, BB_FPS, BB_RENDER_RES,
BB_LIVE_RES, BB_PATCHES, BB_MODS_*, BB_USER_DIR, ...). Extra arguments go to bb-probe.
The in-game "Apply and restart" runs this script again through BB_RESTART_COMMAND;
`--after PID` waits for the previous game process to end first (its GPU device and memory).
"""
import ctypes
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
PORT = Path(__file__).resolve().parent
def fail(message):
print(message, file=sys.stderr)
sys.exit(1)
def wait_for(pid):
kernel32 = ctypes.windll.kernel32
SYNCHRONIZE = 0x00100000
handle = kernel32.OpenProcess(SYNCHRONIZE, False, pid)
if handle:
kernel32.WaitForSingleObject(handle, 30000)
kernel32.CloseHandle(handle)
def no_console():
"""Started without a console (the launcher): console programs must not open their own."""
return 0 if ctypes.windll.kernel32.GetConsoleWindow() else subprocess.CREATE_NO_WINDOW
def run_script(name, *args, capture=False):
# Inside the packaged Bloodborne.exe (PyInstaller) the executable runs scripts itself.
script = ['--script'] if getattr(sys, 'frozen', False) else []
command = [sys.executable, *script, str(PORT / 'scripts' / name), *map(str, args)]
if capture:
result = subprocess.run(command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, text=True,
creationflags=no_console())
if result.returncode:
sys.exit(result.returncode)
return result.stdout
# stdin given: the output handles are passed explicitly (a windowed Bloodborne.exe child would
# get none otherwise).
result = subprocess.run(command, stdin=subprocess.DEVNULL, creationflags=no_console())
if result.returncode:
sys.exit(result.returncode)
return None
def ini_value(path, key):
try:
for line in Path(path).read_text(encoding='utf-8', errors='replace').splitlines():
match = re.fullmatch(rf'{re.escape(key)}=(.*)', line.strip())
if match:
return match.group(1)
except OSError:
pass
return None
def find_executable(name):
"""bin/ (packaged), else out/ (built with build.sh)."""
for candidate in (PORT / 'bin' / name, PORT / 'out' / name):
if candidate.is_file():
return candidate
return None
def main():
args = sys.argv[1:]
if len(args) >= 2 and args[0] == '--after':
wait_for(int(args[1]))
args = args[2:]
# As run.sh: relative paths (BB_GAME_DIR, BB_DATA_DIR, fsr4_shaders/) are from the port.
os.chdir(PORT)
env = os.environ
data = Path(env.get('BB_DATA_DIR', PORT))
out = data / 'out'
out.mkdir(parents=True, exist_ok=True)
env.setdefault('BB_CONFIG', str(data / 'bbport.ini'))
config = env['BB_CONFIG']
if not env.get('BB_FSR411_DIR') and not (PORT / 'fsr4_411').is_dir() and (data / 'fsr4_411').is_dir():
env['BB_FSR411_DIR'] = str(data / 'fsr4_411')
probe = Path(env['BB_PROBE']) if env.get('BB_PROBE') else find_executable('bb-probe.exe')
if not probe or not probe.is_file():
fail('bb-probe.exe not found: build it with build.sh (MSYS2 CLANG64) or use a packaged build.')
# Development builds run from the MSYS2 tree: its CLANG64 DLLs (SDL3, FFmpeg, ...).
if not (probe.parent / 'SDL3.dll').is_file():
clang64 = Path(env.get('MSYS2_ROOT', r'C:\msys64')) / 'clang64' / 'bin'
if clang64.is_dir():
env['PATH'] = f'{clang64}{os.pathsep}{env.get("PATH", "")}'
game = Path(env.get('BB_GAME_DIR', PORT.parent / 'CUSA03173'))
if not (game / 'eboot.bin').is_file():
fail(f'No eboot.bin in {game} (set BB_GAME_DIR).')
original_game = game.resolve()
game = Path(run_script('mods.py', game, '--out', out,
'--mods-dir', env.get('BB_MODS_DIR', data / 'mods'),
'--config', env.get('BB_MODS_CONFIG', data / 'mods.json'),
'--enabled', env.get('BB_MODS_ENABLED', '1'), capture=True).strip())
mod_view = game if game.resolve() != original_game else None
try:
run_script('prepare.py', game, '--out', out)
run_script('link_libc.py', game, '--out', out)
run_script('link_modules.py', game, '--out', out)
run_script('content_profile.py', game, '--out', out, '--sku', env.get('BB_CONTENT_SKU', 'full'))
# Patches exist for game version 01.09 only (patches.py applies none to others): other
# versions keep the game's 30 FPS timing and change resolutions live.
sys.path.insert(0, str(PORT / 'scripts'))
from patches import game_app_version
version = game_app_version(game)
patched = version in (None, '01.09') or bool(env.get('BB_FORCE_PATCHES'))
if not patched:
print(f'Game version {version}: community patches need 01.09; 30 FPS, no effect patches')
# Sizes chosen below for the previous launch are recomputed after an in-game restart.
if env.get('BB_AUTO_RENDER_RES') == '1':
for key in ('BB_RENDER_RES', 'BB_OUTPUT_RES', 'BB_AUTO_RENDER_RES'):
env.pop(key, None)
fps = env.get('BB_FPS', 'uncap') if patched else '30'
scaled_render = scaled_output = None
if not env.get('BB_RENDER_RES'):
printed = run_script('patches.py', '--print-scaled', '--settings', config, capture=True).split()
if len(printed) == 2:
scaled_render, scaled_output = printed
live = '0'
if scaled_output:
live = env.get('BB_LIVE_RES') or ini_value(config, 'live_resolution') or '0'
if live == 'auto':
caps = probe.parent / 'bb-gpu-capabilities.exe'
result = subprocess.run([str(caps), '--live-resolution'], stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
text=True, creationflags=no_console())
live = result.stdout.strip() if result.returncode == 0 else '0'
live = '1' if live == '1' or not patched else '0'
if live == '1':
print(f'Output {scaled_output}: live resolution changes (live_resolution=0: startup patch)')
elif scaled_output:
env.update(BB_RENDER_RES=scaled_render, BB_OUTPUT_RES=scaled_output, BB_AUTO_RENDER_RES='1')
env.setdefault('BB_DMEM_MB', '9152')
print(f'Output {scaled_output}: scene {scaled_render}, direct memory {env["BB_DMEM_MB"]} MiB '
'(live_resolution=1: live changes)')
run_script('patches.py', '--out', out, '--fps', fps, '--extra', env.get('BB_PATCHES', ''),
'--settings', config, '--game-dir', game, '--render-res', env.get('BB_RENDER_RES', ''),
'--output-res', env.get('BB_OUTPUT_RES', ''),
'--patches-dir', env.get('BB_PATCHES_DIR', data / 'patches'),
'--patches-config', env.get('BB_PATCHES_CONFIG', data / 'patches.json'))
if not env.get('BB_VBLANK_HZ'):
env['BB_VBLANK_HZ'] = {'uncap': '0', '90': '90'}.get(fps, '60')
# The in-game restart starts this script again once this process is gone.
# GPU caches (shaders, pipelines) beside the saves; read before main(), so set here.
user_dir = Path(env.get('BB_USER_DIR', data / 'user')).resolve()
user_dir.mkdir(parents=True, exist_ok=True)
env.setdefault('BB_GPU_USER_DIR', str(user_dir))
this = ['--run'] if getattr(sys, 'frozen', False) else [str(Path(__file__).resolve())]
restart = [sys.executable, *this, '--after', str(os.getpid()), *args]
env['BB_RESTART_COMMAND'] = subprocess.list2cmdline(restart)
command = [str(probe), str(out / 'boot-linked.bin'), '--content-profile', str(out / 'content.bin'),
'--patches', str(out / 'patches.bin'), '--app0', str(game),
'--user', str(user_dir),
'--timeout', env.get('BB_TIMEOUT', '0'), *args]
# No stdin: an inherited pipe (shells such as Git Bash) cost the game its console output.
return subprocess.run(command, stdin=subprocess.DEVNULL, creationflags=no_console()).returncode
finally:
if mod_view:
shutil.rmtree(mod_view, ignore_errors=True)
if __name__ == '__main__':
try:
sys.exit(main())
except KeyboardInterrupt:
sys.exit(130)