mirror of
https://github.com/Mrsuss60/bloodborne_pc_windows_port.git
synced 2026-11-07 05:56:20 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3350bb4f5 | ||
|
|
019939a5e5 | ||
|
|
17bfa1e4b7 | ||
|
|
1cc0678c21 | ||
|
|
432d2ddbf1 | ||
|
|
ce83b806b9 | ||
|
|
8d76278ccc | ||
|
|
3e60038643 | ||
|
|
8ea9aa3744 | ||
|
|
34e567b9e4 | ||
|
|
94388d5921 | ||
|
|
d9b1bc6e07 | ||
|
|
8841db5b24 | ||
|
|
ed73550c85 | ||
|
|
2054129fae | ||
|
|
d0f2a891b5 | ||
|
|
574f133635 | ||
|
|
d7b2db1122 | ||
|
|
85d9bc98da | ||
|
|
7889ca068a | ||
|
|
0176a88b3f |
No files matched your search
@@ -7,6 +7,7 @@
|
||||
[](https://github.com/Mrsuss60/bloodborne_pc_windows_port/commits)
|
||||
|
||||
* Discord Server: https://discord.gg/JdZsJ2ArZ2
|
||||
* Download Latest Release: [v0.8.0](https://github.com/Mrsuss60/bloodborne_pc_windows_port/releases/tag/v0.8.0)
|
||||
#
|
||||
`bloodborne_pc_windows_port` is a 64-bit Windows port of the PlayStation 4 executable of *Bloodborne* (CUSA03173, version 1.09).
|
||||
|
||||
|
||||
+354
-98
@@ -4,9 +4,10 @@
|
||||
import ctypes
|
||||
import os
|
||||
from pathlib import Path
|
||||
from PySide6.QtCore import QEvent, QObject, Qt, Signal
|
||||
from PySide6.QtCore import QEvent, QObject, Qt, Signal, QTimer
|
||||
from PySide6.QtGui import QCursor, QKeyEvent, QMouseEvent
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractButton,
|
||||
QAbstractScrollArea,
|
||||
QAbstractSlider,
|
||||
QAbstractSpinBox,
|
||||
@@ -50,6 +51,7 @@ from bblauncher_Utils import (
|
||||
PRESET_CHOICES,
|
||||
RES_CHOICES,
|
||||
SYSTEM_KEYBINDS,
|
||||
TEXTURE_QUALITY_CHOICES,
|
||||
UPSCALER_CHOICES,
|
||||
WINDOW_MODE_CHOICES,
|
||||
build_stamp,
|
||||
@@ -66,7 +68,7 @@ class KeybindButton(QPushButton):
|
||||
keyboard_only=False, display_text=None):
|
||||
if display_text is None:
|
||||
display_text = scancode_display_name(binding_text)
|
||||
super().__init__(display_text, parent)
|
||||
super().__init__(display_text if display_text else "None", parent)
|
||||
self.action_key = action_key
|
||||
# current_binding is always the SDL scancode name written to bbport.ini.
|
||||
self.current_binding = binding_text
|
||||
@@ -81,7 +83,10 @@ class KeybindButton(QPushButton):
|
||||
if keyboard_only:
|
||||
self.setToolTip("Keyboard-only system hotkey: press a key to assign.")
|
||||
self.clicked.connect(self.start_listening)
|
||||
self.update_style()
|
||||
|
||||
# Set the initial state without forcing a stylesheet polish.
|
||||
# This prevents a UI freeze when creating all keybind buttons at once.
|
||||
self.setProperty("listening", "false")
|
||||
|
||||
def update_style(self):
|
||||
if self.listening:
|
||||
@@ -386,6 +391,16 @@ class DialogStyler(QObject):
|
||||
apply_dwm_styling(hwnd)
|
||||
return False
|
||||
|
||||
class CursorGuard(QObject):
|
||||
"""Ensures all clickable controls use the pointing hand cursor."""
|
||||
CLICKABLE_WIDGETS = (QAbstractButton, QComboBox, QSlider)
|
||||
|
||||
def eventFilter(self, obj, event):
|
||||
if event.type() == QEvent.Polish:
|
||||
if isinstance(obj, self.CLICKABLE_WIDGETS):
|
||||
if obj.cursor().shape() == Qt.ArrowCursor:
|
||||
obj.setCursor(Qt.PointingHandCursor)
|
||||
return False
|
||||
|
||||
def install_dialog_styling(app):
|
||||
app.setStyleSheet(STYLE_SHEET)
|
||||
@@ -393,6 +408,10 @@ def install_dialog_styling(app):
|
||||
app.installEventFilter(styler)
|
||||
app._dialog_styler = styler
|
||||
|
||||
cursor_guard = CursorGuard(app)
|
||||
app.installEventFilter(cursor_guard)
|
||||
app._cursor_guard = cursor_guard
|
||||
|
||||
|
||||
STYLE_SHEET = """
|
||||
QMainWindow, QStackedWidget, QDialog {
|
||||
@@ -514,6 +533,7 @@ QPushButton#PrimaryBtn {
|
||||
padding: 10px 18px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
min-height: 20px;
|
||||
}
|
||||
QPushButton#PrimaryBtn:hover {
|
||||
background-color: #b51920;
|
||||
@@ -529,11 +549,67 @@ QPushButton#SecondaryBtn {
|
||||
padding: 7px 14px;
|
||||
border: 1px solid #2e2e38;
|
||||
border-radius: 5px;
|
||||
min-height: 20px;
|
||||
}
|
||||
QPushButton#SecondaryBtn:hover {
|
||||
background-color: #2d2d38;
|
||||
color: #ffffff;
|
||||
}
|
||||
QPushButton#BackupRestoreBtn {
|
||||
background-color: #99141a;
|
||||
color: #ffffff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 0 14px;
|
||||
min-height: 28px;
|
||||
max-height: 28px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
}
|
||||
QPushButton#BackupRestoreBtn:hover {
|
||||
background-color: #b51920;
|
||||
}
|
||||
QPushButton#BackupActionBtn {
|
||||
background-color: #22222a;
|
||||
color: #dcdce0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 0 12px;
|
||||
min-height: 28px;
|
||||
max-height: 28px;
|
||||
border: 1px solid #2e2e38;
|
||||
border-radius: 4px;
|
||||
}
|
||||
QPushButton#BackupActionBtn:hover {
|
||||
background-color: #2d2d38;
|
||||
color: #ffffff;
|
||||
border-color: #3e3e4c;
|
||||
}
|
||||
QPushButton#BackupDeleteBtn {
|
||||
background-color: #24191c;
|
||||
color: #f87171;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 0 12px;
|
||||
min-height: 28px;
|
||||
max-height: 28px;
|
||||
border: 1px solid #451a20;
|
||||
border-radius: 4px;
|
||||
}
|
||||
QPushButton#BackupDeleteBtn:hover {
|
||||
background-color: #3b181e;
|
||||
color: #fca5a5;
|
||||
border-color: #652028;
|
||||
}
|
||||
QFrame#CharSlotCard {
|
||||
background-color: #151720;
|
||||
border: 1px solid #282a36;
|
||||
border-radius: 4px;
|
||||
}
|
||||
QFrame#CharSlotCard:hover {
|
||||
background-color: #181a24;
|
||||
border: 1px solid #8a7a54;
|
||||
}
|
||||
QPushButton#StopBtn {
|
||||
background-color: #361718;
|
||||
color: #e65555;
|
||||
@@ -790,6 +866,7 @@ class LauncherUI:
|
||||
self.btn_patches = QPushButton(" PATCHES")
|
||||
self.btn_display = QPushButton(" DISPLAY && GRAPHICS")
|
||||
self.btn_mk = QPushButton(" MOUSE && KEYBOARD")
|
||||
self.btn_saves = QPushButton(" SAVE MANAGER")
|
||||
self.btn_advanced = QPushButton(" ADVANCED SETTINGS")
|
||||
|
||||
self.nav_buttons = [
|
||||
@@ -797,6 +874,7 @@ class LauncherUI:
|
||||
self.btn_patches,
|
||||
self.btn_display,
|
||||
self.btn_mk,
|
||||
self.btn_saves,
|
||||
self.btn_advanced,
|
||||
]
|
||||
for b in self.nav_buttons:
|
||||
@@ -809,7 +887,8 @@ class LauncherUI:
|
||||
self.btn_patches.clicked.connect(lambda: self.switch_tab(1))
|
||||
self.btn_display.clicked.connect(lambda: self.switch_tab(2))
|
||||
self.btn_mk.clicked.connect(lambda: self.switch_tab(3))
|
||||
self.btn_advanced.clicked.connect(lambda: self.switch_tab(4))
|
||||
self.btn_saves.clicked.connect(lambda: self.switch_tab(4))
|
||||
self.btn_advanced.clicked.connect(lambda: self.switch_tab(5))
|
||||
|
||||
sb_layout.addStretch()
|
||||
|
||||
@@ -864,10 +943,12 @@ class LauncherUI:
|
||||
self.patches_placeholder = QWidget()
|
||||
self.display_placeholder = QWidget()
|
||||
self.mk_placeholder = QWidget()
|
||||
self.saves_placeholder = QWidget()
|
||||
self.advanced_placeholder = QWidget()
|
||||
self.stack.addWidget(self.patches_placeholder)
|
||||
self.stack.addWidget(self.display_placeholder)
|
||||
self.stack.addWidget(self.mk_placeholder)
|
||||
self.stack.addWidget(self.saves_placeholder)
|
||||
self.stack.addWidget(self.advanced_placeholder)
|
||||
right_layout.addWidget(self.stack, 1)
|
||||
|
||||
@@ -899,7 +980,9 @@ class LauncherUI:
|
||||
self._build_display_tab_now()
|
||||
elif idx == 3 and hasattr(self, "_build_mk_tab_now"):
|
||||
self._build_mk_tab_now()
|
||||
elif idx == 4 and hasattr(self, "_build_advanced_tab_now"):
|
||||
elif idx == 4 and hasattr(self, "_build_saves_tab_now"):
|
||||
self._build_saves_tab_now()
|
||||
elif idx == 5 and hasattr(self, "_build_advanced_tab_now"):
|
||||
self._build_advanced_tab_now()
|
||||
for i, btn in enumerate(self.nav_buttons):
|
||||
btn.setChecked(i == idx)
|
||||
@@ -942,10 +1025,15 @@ class LauncherUI:
|
||||
row_exec.setSpacing(8)
|
||||
init_eboot = self.settings.get("eboot", "")
|
||||
if not init_eboot or not Path(init_eboot).exists():
|
||||
init_eboot = str(DEFAULT_GAME / "eboot.bin") if (DEFAULT_GAME / "eboot.bin").exists() else ""
|
||||
if (DEFAULT_GAME / "eboot.bin").exists():
|
||||
init_eboot = str(DEFAULT_GAME / "eboot.bin")
|
||||
elif (DEFAULT_GAME / "eboot.elf").exists():
|
||||
init_eboot = str(DEFAULT_GAME / "eboot.elf")
|
||||
else:
|
||||
init_eboot = ""
|
||||
self.txt_eboot = PathDropEdit(init_eboot)
|
||||
self.txt_eboot.setPlaceholderText("Path to decrypted eboot.bin or game folder...")
|
||||
self.txt_eboot.setToolTip("Drag and drop eboot.bin or the game folder here.")
|
||||
self.txt_eboot.setPlaceholderText("Path to decrypted eboot.bin, eboot.elf or game folder...")
|
||||
self.txt_eboot.setToolTip("Drag and drop eboot.bin, eboot.elf or the game folder here.")
|
||||
self.txt_eboot.textChanged.connect(self.on_eboot_path_changed)
|
||||
self.txt_eboot.path_dropped.connect(self.on_eboot_path_dropped)
|
||||
row_exec.addWidget(self.txt_eboot, 1)
|
||||
@@ -1216,15 +1304,30 @@ class LauncherUI:
|
||||
top_row = QHBoxLayout()
|
||||
cb = QCheckBox(name.replace("&", "&&"))
|
||||
cb.setStyleSheet("font-weight: 700; color: #ffffff;")
|
||||
# Create the warning label
|
||||
warn_lbl = QLabel("(Disabled because Live FPS is On)")
|
||||
warn_lbl.setStyleSheet("color: #f0b429; font-size: 11px; font-weight: 600; margin-left: 8px;")
|
||||
warn_lbl.setVisible(False)
|
||||
|
||||
if not hasattr(self, "patch_warnings"):
|
||||
self.patch_warnings = {}
|
||||
self.patch_warnings[name] = warn_lbl
|
||||
|
||||
is_live = hasattr(self, "chk_fps_patch") and self.chk_fps_patch.isChecked()
|
||||
if is_live and name in LIVE_FPS_CONFLICTING_PATCHES:
|
||||
cb.setChecked(False)
|
||||
cb.setEnabled(False)
|
||||
cb.setToolTip("Disabled: Conflicts with Live FPS Patching.")
|
||||
warn_lbl.setVisible(True)
|
||||
else:
|
||||
cb.setChecked(name in self.enabled_patches)
|
||||
|
||||
cb.stateChanged.connect(lambda state, p=name: self.on_patch_toggled(p, state))
|
||||
top_row.addWidget(cb, 1)
|
||||
|
||||
# Add checkbox, warning label, stretch, and then the meta label
|
||||
top_row.addWidget(cb, 0)
|
||||
top_row.addWidget(warn_lbl)
|
||||
top_row.addStretch(1)
|
||||
|
||||
meta_lbl = QLabel(f'<span style="color:#7b7b88;">by {author}</span> <span style="color:#d1b87a;font-weight:600;">[{cat}]</span>')
|
||||
meta_lbl.setTextFormat(Qt.RichText)
|
||||
@@ -1381,8 +1484,8 @@ class LauncherUI:
|
||||
w, c_layout = self._create_page("Display & Graphics")
|
||||
ini_opts = self.load_bbport_ini()
|
||||
|
||||
# ---- Display & render core ----
|
||||
card_disp, l_disp = self._new_card("Display & Render Core")
|
||||
# ---- Display & render settings ----
|
||||
card_disp, l_disp = self._new_card("Display & Render Settings")
|
||||
|
||||
grid_disp = QGridLayout()
|
||||
grid_disp.setHorizontalSpacing(16)
|
||||
@@ -1428,7 +1531,81 @@ class LauncherUI:
|
||||
self.cmb_prep_workers.currentIndexChanged.connect(lambda: (self.save_settings(), self.save_bbport_ini()))
|
||||
grid_disp.addWidget(self.cmb_prep_workers, 2, 3)
|
||||
|
||||
grid_disp.addWidget(QLabel("Upscaler Technology:"), 3, 0)
|
||||
grid_disp.addWidget(QLabel("GPU Adapter:"), 3, 0)
|
||||
self.cmb_gpu = QComboBox()
|
||||
self.fill_gpu_choices()
|
||||
self.cmb_gpu.currentIndexChanged.connect(lambda: (self.save_settings(), self.save_bbport_ini()))
|
||||
grid_disp.addWidget(self.cmb_gpu, 3, 1)
|
||||
|
||||
grid_disp.addWidget(QLabel("Live Resolution:"), 3, 2)
|
||||
self.cmb_live_res = QComboBox()
|
||||
for label, value in LIVE_RES_CHOICES:
|
||||
self.cmb_live_res.addItem(label, value)
|
||||
live_idx = self.cmb_live_res.findData(ini_opts.get("live_resolution", "0").strip().lower())
|
||||
self.cmb_live_res.setCurrentIndex(live_idx if live_idx >= 0 else 0)
|
||||
self.cmb_live_res.currentIndexChanged.connect(lambda: self.save_bbport_ini())
|
||||
grid_disp.addWidget(self.cmb_live_res, 3, 3)
|
||||
|
||||
grid_disp.addWidget(QLabel("Async Pipelines:"), 4, 0)
|
||||
self.cmb_async_pipelines = QComboBox()
|
||||
for label, value in ASYNC_PIPELINE_CHOICES:
|
||||
self.cmb_async_pipelines.addItem(label, value)
|
||||
cur_async = str(ini_opts.get("async_pipelines", self.settings.get("async_pipelines", "auto"))).strip().lower()
|
||||
async_idx = self.cmb_async_pipelines.findData(cur_async)
|
||||
self.cmb_async_pipelines.setCurrentIndex(async_idx if async_idx >= 0 else 0)
|
||||
self.cmb_async_pipelines.currentIndexChanged.connect(lambda: (self.save_settings(), self.save_bbport_ini()))
|
||||
grid_disp.addWidget(self.cmb_async_pipelines, 4, 1)
|
||||
|
||||
grid_disp.addWidget(QLabel("Game Text Language:"), 4, 2)
|
||||
self.cmb_lang = QComboBox()
|
||||
for label, code in LANGUAGE_CHOICES:
|
||||
self.cmb_lang.addItem(label, code)
|
||||
cur_lang = self.settings.get("language", "1")
|
||||
lang_idx = next((i for i, (_, c) in enumerate(LANGUAGE_CHOICES) if c == cur_lang), 0)
|
||||
self.cmb_lang.setCurrentIndex(lang_idx)
|
||||
self.cmb_lang.currentIndexChanged.connect(lambda: (self.save_settings(), self.save_bbport_ini()))
|
||||
grid_disp.addWidget(self.cmb_lang, 4, 3)
|
||||
|
||||
lbl_tq = QLabel("Texture Quality:")
|
||||
lbl_tq.setToolTip("The size of large textures in VRAM. For GPUs with 2-4 GB.\nHalf with 3 GB of VRAM or less, else full.")
|
||||
grid_disp.addWidget(lbl_tq, 5, 0)
|
||||
self.cmb_texture_quality = QComboBox()
|
||||
self.cmb_texture_quality.setToolTip("The size of large textures in VRAM. For GPUs with 2-4 GB.\nHalf with 3 GB of VRAM or less, else full.")
|
||||
for label, value in TEXTURE_QUALITY_CHOICES:
|
||||
self.cmb_texture_quality.addItem(label, value)
|
||||
cur_tex = str(ini_opts.get("texture_quality", self.settings.get("texture_quality", "auto"))).strip().lower()
|
||||
tex_idx = self.cmb_texture_quality.findData(cur_tex)
|
||||
self.cmb_texture_quality.setCurrentIndex(tex_idx if tex_idx >= 0 else 0)
|
||||
self.cmb_texture_quality.currentIndexChanged.connect(lambda: (self.save_settings(), self.save_bbport_ini()))
|
||||
grid_disp.addWidget(self.cmb_texture_quality, 5, 1)
|
||||
|
||||
l_disp.addLayout(grid_disp)
|
||||
|
||||
row_flags = QGridLayout()
|
||||
row_flags.setHorizontalSpacing(16)
|
||||
row_flags.setVerticalSpacing(8)
|
||||
|
||||
self.chk_fps_patch = QCheckBox("Enable Live FPS Patching (60/90/120/Uncap)")
|
||||
self.chk_fps_patch.setChecked(self.settings.get("feat_fps_patch", True))
|
||||
row_flags.addWidget(self.chk_fps_patch, 0, 0)
|
||||
self._populate_fps_combo(self.chk_fps_patch.isChecked(), self.settings.get("fps", "60"))
|
||||
self.chk_fps_patch.toggled.connect(self._on_fps_patch_toggled)
|
||||
self.cmb_fps.currentIndexChanged.connect(lambda: (self.save_settings(), self.save_bbport_ini()))
|
||||
|
||||
l_disp.addLayout(row_flags)
|
||||
self._save_footer(l_disp)
|
||||
c_layout.addWidget(card_disp)
|
||||
|
||||
# ---- Upscaler settings ----
|
||||
card_q, l_q = self._new_card("Upscaler Settings (FSR & DLSS)")
|
||||
|
||||
grid_up = QGridLayout()
|
||||
grid_up.setHorizontalSpacing(16)
|
||||
grid_up.setVerticalSpacing(10)
|
||||
grid_up.setColumnStretch(1, 1)
|
||||
grid_up.setColumnStretch(3, 1)
|
||||
|
||||
grid_up.addWidget(QLabel("Upscaler Technology:"), 0, 0)
|
||||
self.cmb_upscaler = QComboBox()
|
||||
for label, value in UPSCALER_CHOICES:
|
||||
self.cmb_upscaler.addItem(label, value)
|
||||
@@ -1438,99 +1615,20 @@ class LauncherUI:
|
||||
upscaler_idx = self.cmb_upscaler.findText(init_upscaler)
|
||||
if upscaler_idx >= 0:
|
||||
self.cmb_upscaler.setCurrentIndex(upscaler_idx)
|
||||
grid_disp.addWidget(self.cmb_upscaler, 3, 1)
|
||||
self.cmb_upscaler.currentIndexChanged.connect(lambda: (self.save_settings(), self.save_bbport_ini()))
|
||||
grid_up.addWidget(self.cmb_upscaler, 0, 1)
|
||||
|
||||
grid_disp.addWidget(QLabel("Upscaler Preset:"), 3, 2)
|
||||
grid_up.addWidget(QLabel("Upscaler Preset:"), 0, 2)
|
||||
self.cmb_preset = QComboBox()
|
||||
for label, value in PRESET_CHOICES:
|
||||
self.cmb_preset.addItem(label, value)
|
||||
preset_idx = self.cmb_preset.findData(ini_opts.get("preset", "1").strip())
|
||||
self.cmb_preset.setCurrentIndex(preset_idx if preset_idx >= 0 else 0)
|
||||
self.cmb_preset.currentIndexChanged.connect(lambda: self.save_bbport_ini())
|
||||
grid_disp.addWidget(self.cmb_preset, 3, 3)
|
||||
grid_up.addWidget(self.cmb_preset, 0, 3)
|
||||
|
||||
grid_disp.addWidget(QLabel("GPU Adapter:"), 4, 0)
|
||||
self.cmb_gpu = QComboBox()
|
||||
self.fill_gpu_choices()
|
||||
self.cmb_gpu.currentIndexChanged.connect(lambda: (self.save_settings(), self.save_bbport_ini()))
|
||||
grid_disp.addWidget(self.cmb_gpu, 4, 1)
|
||||
l_q.addLayout(grid_up)
|
||||
|
||||
grid_disp.addWidget(QLabel("Live Resolution:"), 4, 2)
|
||||
self.cmb_live_res = QComboBox()
|
||||
for label, value in LIVE_RES_CHOICES:
|
||||
self.cmb_live_res.addItem(label, value)
|
||||
live_idx = self.cmb_live_res.findData(ini_opts.get("live_resolution", "0").strip().lower())
|
||||
self.cmb_live_res.setCurrentIndex(live_idx if live_idx >= 0 else 0)
|
||||
self.cmb_live_res.currentIndexChanged.connect(lambda: self.save_bbport_ini())
|
||||
grid_disp.addWidget(self.cmb_live_res, 4, 3)
|
||||
|
||||
grid_disp.addWidget(QLabel("Async Pipelines:"), 5, 0)
|
||||
self.cmb_async_pipelines = QComboBox()
|
||||
for label, value in ASYNC_PIPELINE_CHOICES:
|
||||
self.cmb_async_pipelines.addItem(label, value)
|
||||
cur_async = str(ini_opts.get("async_pipelines", self.settings.get("async_pipelines", "auto"))).strip().lower()
|
||||
async_idx = self.cmb_async_pipelines.findData(cur_async)
|
||||
self.cmb_async_pipelines.setCurrentIndex(async_idx if async_idx >= 0 else 0)
|
||||
self.cmb_async_pipelines.currentIndexChanged.connect(lambda: (self.save_settings(), self.save_bbport_ini()))
|
||||
grid_disp.addWidget(self.cmb_async_pipelines, 5, 1)
|
||||
|
||||
grid_disp.addWidget(QLabel("Game Text Language:"), 5, 2)
|
||||
self.cmb_lang = QComboBox()
|
||||
for label, code in LANGUAGE_CHOICES:
|
||||
self.cmb_lang.addItem(label, code)
|
||||
cur_lang = self.settings.get("language", "1")
|
||||
lang_idx = next((i for i, (_, c) in enumerate(LANGUAGE_CHOICES) if c == cur_lang), 0)
|
||||
self.cmb_lang.setCurrentIndex(lang_idx)
|
||||
self.cmb_lang.currentIndexChanged.connect(lambda: (self.save_settings(), self.save_bbport_ini()))
|
||||
grid_disp.addWidget(self.cmb_lang, 5, 3)
|
||||
|
||||
l_disp.addLayout(grid_disp)
|
||||
|
||||
row_flags = QGridLayout()
|
||||
row_flags.setHorizontalSpacing(16)
|
||||
row_flags.setVerticalSpacing(8)
|
||||
|
||||
self.chk_fsr = QCheckBox("Enable Temporal Upscaler")
|
||||
self.chk_fsr.setChecked(self.settings.get("feat_upscaler", False) and self.cmb_upscaler.currentData() != "off")
|
||||
row_flags.addWidget(self.chk_fsr, 0, 0)
|
||||
|
||||
self.chk_fps_patch = QCheckBox("Enable Live FPS Patching (60/90/120/Uncap)")
|
||||
self.chk_fps_patch.setChecked(self.settings.get("feat_fps_patch", True))
|
||||
row_flags.addWidget(self.chk_fps_patch, 0, 1)
|
||||
self._populate_fps_combo(self.chk_fps_patch.isChecked(), self.settings.get("fps", "60"))
|
||||
self.chk_fps_patch.toggled.connect(self._on_fps_patch_toggled)
|
||||
self.cmb_fps.currentIndexChanged.connect(lambda: (self.save_settings(), self.save_bbport_ini()))
|
||||
|
||||
def on_upscaler_combo_changed():
|
||||
active = self.cmb_upscaler.currentData() != "off"
|
||||
self.chk_fsr.blockSignals(True)
|
||||
self.chk_fsr.setChecked(active)
|
||||
self.chk_fsr.blockSignals(False)
|
||||
self.save_settings()
|
||||
self.save_bbport_ini()
|
||||
|
||||
def on_chk_fsr_toggled(checked):
|
||||
self.cmb_upscaler.blockSignals(True)
|
||||
if not checked:
|
||||
self.cmb_upscaler.setCurrentIndex(self.cmb_upscaler.findData("off"))
|
||||
elif self.cmb_upscaler.currentData() == "off":
|
||||
self.cmb_upscaler.setCurrentIndex(self.cmb_upscaler.findData("fsr31"))
|
||||
self.cmb_upscaler.blockSignals(False)
|
||||
self.save_settings()
|
||||
self.save_bbport_ini()
|
||||
|
||||
self.cmb_upscaler.currentIndexChanged.connect(on_upscaler_combo_changed)
|
||||
self.chk_fsr.toggled.connect(on_chk_fsr_toggled)
|
||||
|
||||
l_disp.addLayout(row_flags)
|
||||
self._save_footer(l_disp)
|
||||
c_layout.addWidget(card_disp)
|
||||
|
||||
# ---- Upscaler quality ----
|
||||
card_q, l_q = self._new_card(
|
||||
"Upscaler Quality",
|
||||
"Applies while the upscaler is active. The same values are available in the in-game menu.",
|
||||
)
|
||||
grid_q = QGridLayout()
|
||||
grid_q.setHorizontalSpacing(16)
|
||||
grid_q.setVerticalSpacing(10)
|
||||
@@ -1901,6 +1999,163 @@ class LauncherUI:
|
||||
c_layout.addStretch()
|
||||
return w
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# SAVE GAME MANAGER
|
||||
# ------------------------------------------------------------------
|
||||
def create_saves_tab(self) -> QWidget:
|
||||
w, c_layout = self._create_page("Save Game Manager")
|
||||
s = self.settings
|
||||
|
||||
# ---- Active Save Card ----
|
||||
card_active, l_active = self._new_card(
|
||||
"Active Game Save",
|
||||
"The save data Bloodborne loads and writes to during gameplay.",
|
||||
)
|
||||
|
||||
row_active_top = QHBoxLayout()
|
||||
row_active_top.setSpacing(12)
|
||||
|
||||
v_info = QVBoxLayout()
|
||||
v_info.setSpacing(4)
|
||||
self.lbl_active_save_path = QLabel("Checking save directory...")
|
||||
self.lbl_active_save_path.setStyleSheet("color: #d8d8e0; font-size: 13px; font-weight: 600;")
|
||||
self.lbl_active_save_meta = QLabel("-")
|
||||
self.lbl_active_save_meta.setStyleSheet("color: #8c8c9e; font-size: 11px;")
|
||||
v_info.addWidget(self.lbl_active_save_path)
|
||||
v_info.addWidget(self.lbl_active_save_meta)
|
||||
row_active_top.addLayout(v_info, 1)
|
||||
|
||||
ROW_BTN_HEIGHT = 38
|
||||
|
||||
self.btn_create_backup = QPushButton("Create Backup")
|
||||
self.btn_create_backup.setObjectName("PrimaryBtn")
|
||||
self.btn_create_backup.setMinimumHeight(ROW_BTN_HEIGHT)
|
||||
if hasattr(self, "handle_create_backup"):
|
||||
self.btn_create_backup.clicked.connect(self.handle_create_backup)
|
||||
row_active_top.addWidget(self.btn_create_backup)
|
||||
|
||||
self.btn_open_saves_folder = QPushButton("Open Folder")
|
||||
self.btn_open_saves_folder.setObjectName("SecondaryBtn")
|
||||
self.btn_open_saves_folder.setMinimumHeight(ROW_BTN_HEIGHT)
|
||||
if hasattr(self, "handle_open_saves_folder"):
|
||||
self.btn_open_saves_folder.clicked.connect(self.handle_open_saves_folder)
|
||||
row_active_top.addWidget(self.btn_open_saves_folder)
|
||||
|
||||
self.btn_export_active = QPushButton("Export ZIP")
|
||||
self.btn_export_active.setObjectName("SecondaryBtn")
|
||||
self.btn_export_active.setMinimumHeight(ROW_BTN_HEIGHT)
|
||||
if hasattr(self, "handle_export_active_save"):
|
||||
self.btn_export_active.clicked.connect(self.handle_export_active_save)
|
||||
row_active_top.addWidget(self.btn_export_active)
|
||||
|
||||
self.btn_import_save = QPushButton("Import Save")
|
||||
self.btn_import_save.setObjectName("SecondaryBtn")
|
||||
self.btn_import_save.setMinimumHeight(ROW_BTN_HEIGHT)
|
||||
if hasattr(self, "handle_import_save"):
|
||||
self.btn_import_save.clicked.connect(self.handle_import_save)
|
||||
row_active_top.addWidget(self.btn_import_save)
|
||||
|
||||
l_active.addLayout(row_active_top)
|
||||
|
||||
# Character Slots Display for Active Save
|
||||
self.frame_active_characters = QFrame()
|
||||
self.frame_active_characters.setStyleSheet(
|
||||
"background: transparent; border: none; padding: 0px;"
|
||||
)
|
||||
self.layout_active_characters = QVBoxLayout(self.frame_active_characters)
|
||||
self.layout_active_characters.setContentsMargins(0, 8, 0, 0)
|
||||
self.layout_active_characters.setSpacing(8)
|
||||
l_active.addWidget(self.frame_active_characters)
|
||||
|
||||
c_layout.addWidget(card_active)
|
||||
|
||||
# ---- Backups List Card ----
|
||||
card_backups, l_backups = self._new_card(
|
||||
"Saved Backups",
|
||||
"Restoring any backup will automatically snapshot your current active save first to prevent progress loss.",
|
||||
)
|
||||
|
||||
row_b_header = QHBoxLayout()
|
||||
row_b_header.setSpacing(8)
|
||||
self.lbl_backup_count = QLabel("0 Backups available")
|
||||
self.lbl_backup_count.setStyleSheet("color: #8c8c9e; font-size: 11px;")
|
||||
row_b_header.addWidget(self.lbl_backup_count)
|
||||
row_b_header.addStretch()
|
||||
|
||||
self.btn_refresh_backups = QPushButton("Refresh")
|
||||
self.btn_refresh_backups.setObjectName("SecondaryBtn")
|
||||
self.btn_refresh_backups.setMinimumHeight(32)
|
||||
if hasattr(self, "refresh_save_manager"):
|
||||
self.btn_refresh_backups.clicked.connect(self.refresh_save_manager)
|
||||
row_b_header.addWidget(self.btn_refresh_backups)
|
||||
l_backups.addLayout(row_b_header)
|
||||
|
||||
# Container for backup items
|
||||
self.backups_container = QWidget()
|
||||
self.backups_container.setObjectName("ScrollContent")
|
||||
self.layout_backups_list = QVBoxLayout(self.backups_container)
|
||||
self.layout_backups_list.setContentsMargins(0, 4, 0, 4)
|
||||
self.layout_backups_list.setSpacing(8)
|
||||
l_backups.addWidget(self.backups_container)
|
||||
|
||||
self.lbl_no_backups = QLabel(
|
||||
"No backups saved yet. Click 'Create Backup' above or launch the game with auto-backup enabled."
|
||||
)
|
||||
self.lbl_no_backups.setStyleSheet(
|
||||
"color: #6a6a78; font-size: 12px; font-style: italic; padding: 16px 0;"
|
||||
)
|
||||
self.lbl_no_backups.setAlignment(Qt.AlignCenter)
|
||||
l_backups.addWidget(self.lbl_no_backups)
|
||||
|
||||
c_layout.addWidget(card_backups)
|
||||
|
||||
# ---- Safeguards & Automations Card ----
|
||||
card_auto, l_auto = self._new_card(
|
||||
"Automatic Safeguards",
|
||||
"Protection against accidental New Game+ transitions, save file corruption, or lost progress.",
|
||||
)
|
||||
|
||||
grid_auto = QGridLayout()
|
||||
grid_auto.setHorizontalSpacing(16)
|
||||
grid_auto.setVerticalSpacing(10)
|
||||
|
||||
self.chk_auto_backup_launch = QCheckBox("Auto-backup before game launch")
|
||||
self.chk_auto_backup_launch.setChecked(s.get("auto_backup_on_launch", True))
|
||||
self.chk_auto_backup_launch.toggled.connect(lambda: self.save_settings())
|
||||
grid_auto.addWidget(self.chk_auto_backup_launch, 0, 0)
|
||||
|
||||
row_prune = QHBoxLayout()
|
||||
row_prune.setSpacing(8)
|
||||
row_prune.addWidget(QLabel("Keep last:"))
|
||||
self.cmb_max_backups = QComboBox()
|
||||
for count in [5, 10, 20, 50]:
|
||||
self.cmb_max_backups.addItem(f"{count} pre-launch backups", count)
|
||||
cur_max = s.get("max_rolling_backups", 10)
|
||||
idx_max = self.cmb_max_backups.findData(cur_max)
|
||||
self.cmb_max_backups.setCurrentIndex(idx_max if idx_max >= 0 else 1)
|
||||
self.cmb_max_backups.currentIndexChanged.connect(lambda: self.save_settings())
|
||||
row_prune.addWidget(self.cmb_max_backups)
|
||||
row_prune.addStretch()
|
||||
grid_auto.addLayout(row_prune, 0, 1)
|
||||
|
||||
self.chk_engine_safepoint = QCheckBox("Engine-level Safepoint Auto-backup")
|
||||
self.chk_engine_safepoint.setToolTip(
|
||||
"Instructs the C runtime to automatically snapshot save data before triggering NG+ ending sequences."
|
||||
)
|
||||
ini_opts = self.load_bbport_ini() if hasattr(self, "load_bbport_ini") else {}
|
||||
self.chk_engine_safepoint.setChecked(
|
||||
ini_opts.get("save_auto_backup", "1").strip().lower() in ("1", "true")
|
||||
)
|
||||
if hasattr(self, "_sync_save_auto_backup_to_ini"):
|
||||
self.chk_engine_safepoint.toggled.connect(self._sync_save_auto_backup_to_ini)
|
||||
grid_auto.addWidget(self.chk_engine_safepoint, 1, 0, 1, 2)
|
||||
|
||||
l_auto.addLayout(grid_auto)
|
||||
c_layout.addWidget(card_auto)
|
||||
c_layout.addStretch()
|
||||
|
||||
return w
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# ADVANCED SETTINGS
|
||||
# ------------------------------------------------------------------
|
||||
@@ -2052,5 +2307,6 @@ class LauncherUI:
|
||||
c_layout.addWidget(card_files)
|
||||
|
||||
c_layout.addStretch()
|
||||
self.update_cache_info()
|
||||
return w
|
||||
# Defer the slow I/O scan so the tab paints instantly
|
||||
QTimer.singleShot(50, self.update_cache_info)
|
||||
return w
|
||||
@@ -3,11 +3,16 @@
|
||||
|
||||
import ctypes
|
||||
import hashlib
|
||||
import json
|
||||
import locale
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import zipfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from PySide6.QtCore import QThread, Signal
|
||||
@@ -28,7 +33,7 @@ LOG_KEEP = 20
|
||||
SETTINGS_FILE = APP_DIR / "bblauncher_settings.json"
|
||||
PATCHES_XML = APP_DIR / "patches" / "Bloodborne.xml"
|
||||
DEFAULT_GAME = Path(os.environ.get("BB_GAME_DIR", "../CUSA03173"))
|
||||
LAUNCHER_VERSION = "0.7.2"
|
||||
LAUNCHER_VERSION = "0.9.0"
|
||||
|
||||
|
||||
def build_stamp() -> str:
|
||||
@@ -93,6 +98,12 @@ ASYNC_PIPELINE_CHOICES = [
|
||||
("4 Worker Threads", "4"),
|
||||
("8 Worker Threads", "8"),
|
||||
]
|
||||
TEXTURE_QUALITY_CHOICES = [
|
||||
("Auto (Half on <=3GB VRAM, else Full)", "auto"),
|
||||
("Full (Original quality)", "full"),
|
||||
("Half (50% size)", "half"),
|
||||
("Quarter (25% size)", "quarter"),
|
||||
]
|
||||
DEBUG_VIEW_CHOICES = [
|
||||
("Off", "0"),
|
||||
("Reactive mask", "1"),
|
||||
@@ -194,6 +205,10 @@ DEFAULT_SETTINGS = {
|
||||
"aniso": "2x",
|
||||
"prep_workers": PREP_WORKER_CHOICES[0][0],
|
||||
"async_pipelines": "auto",
|
||||
"texture_quality": "auto",
|
||||
"auto_backup_on_launch": True,
|
||||
"max_rolling_backups": 10,
|
||||
"save_auto_backup": True,
|
||||
"anti_aliasing": AA_CHOICES[0],
|
||||
"gpu": "",
|
||||
"language": "1",
|
||||
@@ -424,12 +439,23 @@ def shader_cache_dir() -> Path:
|
||||
return (user if user.is_absolute() else APP_DIR / user) / "cache"
|
||||
|
||||
|
||||
def find_eboot_file(game_dir: Path) -> Path | None:
|
||||
if not game_dir or not game_dir.is_dir():
|
||||
return None
|
||||
for name in ("eboot.bin", "eboot.elf"):
|
||||
candidate = game_dir / name
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def resolve_game_dir(target: str):
|
||||
p = Path(target.strip().strip('"'))
|
||||
if p.is_file() and p.name.lower() == "eboot.bin":
|
||||
if p.is_file() and p.name.lower() in ("eboot.bin", "eboot.elf"):
|
||||
return p.parent
|
||||
if p.is_dir() and (p / "eboot.bin").is_file():
|
||||
return p
|
||||
if p.is_dir():
|
||||
if (p / "eboot.bin").is_file() or (p / "eboot.elf").is_file():
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
@@ -584,7 +610,9 @@ def check_live_clock_signature(game_dir: Path) -> tuple[bool, str]:
|
||||
helper the game check uses, then read the guarded windows from the ELF image so a
|
||||
different eboot is reported instead of hooked (probe.c falls back to XML patches).
|
||||
"""
|
||||
eboot_path = game_dir / "eboot.bin"
|
||||
eboot_path = find_eboot_file(game_dir)
|
||||
if not eboot_path:
|
||||
return False, "could not find eboot.bin or eboot.elf"
|
||||
try:
|
||||
data = eboot_path.read_bytes()
|
||||
except OSError as ex:
|
||||
@@ -594,7 +622,7 @@ def check_live_clock_signature(game_dir: Path) -> tuple[bool, str]:
|
||||
if scripts_dir not in sys.path:
|
||||
sys.path.insert(0, scripts_dir)
|
||||
from prepare import parse_self
|
||||
elf, _header, ph, _segments, _missing = parse_self(data, "eboot.bin")
|
||||
elf, _header, ph, _segments, _missing = parse_self(data, eboot_path.name)
|
||||
except Exception as ex:
|
||||
return False, f"eboot.bin could not be parsed ({ex})"
|
||||
loads = [p for p in ph if p["type"] in (1, 0x61000010)]
|
||||
@@ -620,13 +648,13 @@ def check_live_clock_signature(game_dir: Path) -> tuple[bool, str]:
|
||||
def verify_prelaunch(game_dir: Path) -> tuple[bool, str, list[str]]:
|
||||
details = []
|
||||
|
||||
eboot_path = game_dir / "eboot.bin"
|
||||
if not eboot_path.is_file():
|
||||
return False, f"Missing eboot.bin in game directory:\n{game_dir}", details
|
||||
eboot_path = find_eboot_file(game_dir)
|
||||
if not eboot_path:
|
||||
return False, f"Missing eboot.bin or eboot.elf in game directory:\n{game_dir}", details
|
||||
eboot_size = eboot_path.stat().st_size
|
||||
if eboot_size == 0:
|
||||
return False, f"eboot.bin is empty in:\n{eboot_path}", details
|
||||
details.append(f"[OK] Game executable: eboot.bin ({eboot_size / (1024*1024):.1f} MiB)")
|
||||
return False, f"{eboot_path.name} is empty in:\n{eboot_path}", details
|
||||
details.append(f"[OK] Game executable: {eboot_path.name} ({eboot_size / (1024*1024):.1f} MiB)")
|
||||
|
||||
try:
|
||||
scripts_dir = str(APP_DIR / "scripts")
|
||||
@@ -801,4 +829,740 @@ class ProcessWorker(QThread):
|
||||
|
||||
def terminate_proc(self):
|
||||
self._stop_requested = True
|
||||
kill_tree(self.proc)
|
||||
kill_tree(self.proc)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Save Game Manager Utilities
|
||||
# ==============================================================================
|
||||
|
||||
def format_playtime(seconds: int) -> str:
|
||||
"""Format total seconds into human-readable hours and minutes."""
|
||||
h = max(0, seconds) // 3600
|
||||
m = (max(0, seconds) % 3600) // 60
|
||||
if h > 0:
|
||||
return f"{h}h {m:02d}m"
|
||||
s = max(0, seconds) % 60
|
||||
return f"{m}m {s:02d}s"
|
||||
|
||||
|
||||
def format_size(size_bytes: int) -> str:
|
||||
"""Format byte count into human-readable KB or MB."""
|
||||
if size_bytes < 1024:
|
||||
return f"{size_bytes} B"
|
||||
if size_bytes < 1024 * 1024:
|
||||
return f"{size_bytes / 1024:.1f} KB"
|
||||
return f"{size_bytes / (1024 * 1024):.1f} MB"
|
||||
|
||||
|
||||
BLOODBORNE_MAP_NAMES = {
|
||||
2000: "1st Floor Sickroom",
|
||||
2100: "Hunter's Dream",
|
||||
2101: "Hunter's Nightmare",
|
||||
2200: "Research Hall",
|
||||
2300: "Fishing Hamlet",
|
||||
2400: "Central Yharnam",
|
||||
2410: "Central Yharnam",
|
||||
2420: "Cathedral Ward",
|
||||
2421: "Grand Cathedral",
|
||||
2500: "Forsaken Cainhurst Castle",
|
||||
2600: "Hemwick Charnel Lane",
|
||||
2700: "Yahar'gul, Unseen Village",
|
||||
2800: "Upper Cathedral Ward",
|
||||
2900: "Castle Cainhurst",
|
||||
3200: "Old Yharnam",
|
||||
3300: "Healing Church Workshop",
|
||||
3400: "Forbidden Woods",
|
||||
3500: "Byrgenwerth",
|
||||
3600: "Yahar'gul (Blood Moon)",
|
||||
3700: "Nightmare of Mensis",
|
||||
3800: "Lecture Building",
|
||||
3900: "Nightmare Frontier",
|
||||
}
|
||||
|
||||
|
||||
def find_character_portrait(
|
||||
slot_idx: int = 0,
|
||||
char_name: str = "",
|
||||
save_dir: Path = None,
|
||||
base_dir: Path = None,
|
||||
) -> Path | None:
|
||||
"""Find the universal character portrait image file for save game slot cards.
|
||||
|
||||
Returns the universal Bloodborne Hunter cover artwork embedded in the launcher
|
||||
or located in resources/portraits.
|
||||
"""
|
||||
root = (base_dir or APP_DIR)
|
||||
|
||||
# Search candidates across bundled PyInstaller runtime and local directories
|
||||
candidate_dirs: list[Path] = []
|
||||
|
||||
# 1. PyInstaller runtime unpacked directory
|
||||
if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
|
||||
meipass_path = Path(sys._MEIPASS)
|
||||
candidate_dirs.append(meipass_path / "resources" / "portraits")
|
||||
candidate_dirs.append(meipass_path / "resources")
|
||||
candidate_dirs.append(meipass_path)
|
||||
|
||||
# 2. Local app and script directories
|
||||
this_dir = Path(__file__).resolve().parent
|
||||
candidate_dirs.extend([
|
||||
this_dir / "resources" / "portraits",
|
||||
this_dir.parent / "resources" / "portraits",
|
||||
root / "bblauncherAPP" / "resources" / "portraits",
|
||||
root / "resources" / "portraits",
|
||||
])
|
||||
|
||||
image_names = [
|
||||
"default_hunter.png",
|
||||
"default_hunter (2).png",
|
||||
"default_hunter.jpg",
|
||||
]
|
||||
|
||||
for pdir in candidate_dirs:
|
||||
for name in image_names:
|
||||
img = pdir / name
|
||||
if img.is_file():
|
||||
return img
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def parse_userdata0010(file_path: Path) -> list[dict]:
|
||||
"""Parse character slot table from unencrypted Bloodborne userdata0010 file.
|
||||
|
||||
Layout:
|
||||
Table base: 0x1080 (10 slots, 0x192 / 402 bytes stride)
|
||||
Slot relative offsets:
|
||||
+0x16: Character Name (UTF-16LE, up to 32 bytes / 16 chars)
|
||||
+0x38: Level (uint32)
|
||||
+0x3C: Playtime in seconds (uint32)
|
||||
+0x40: Blood Echoes (uint32)
|
||||
+0x48: Map / Location ID (uint32)
|
||||
"""
|
||||
slots = []
|
||||
try:
|
||||
p = Path(file_path)
|
||||
if not p.is_file():
|
||||
return []
|
||||
data = p.read_bytes()
|
||||
# Verify minimal size to hold 10 slot entries (0x1EA2 + 0x4C = 7918 bytes)
|
||||
if len(data) < 0x2000:
|
||||
return []
|
||||
|
||||
for i in range(10):
|
||||
base = 0x1080 + i * 0x192
|
||||
if base + 0x4C > len(data):
|
||||
break
|
||||
name_raw = (
|
||||
data[base + 0x16 : base + 0x16 + 32]
|
||||
.decode("utf-16le", errors="ignore")
|
||||
.split("\x00")[0]
|
||||
.strip()
|
||||
)
|
||||
name_clean = "".join(ch for ch in name_raw if ch.isprintable()).strip()
|
||||
level, playtime_secs, echoes = struct.unpack_from("<III", data, base + 0x38)
|
||||
area_id = struct.unpack_from("<I", data, base + 0x48)[0] if base + 0x4C <= len(data) else 0
|
||||
if name_clean and (level > 0 or playtime_secs > 0):
|
||||
h = int(playtime_secs) // 3600
|
||||
m = (int(playtime_secs) % 3600) // 60
|
||||
s = int(playtime_secs) % 60
|
||||
location = BLOODBORNE_MAP_NAMES.get(
|
||||
int(area_id),
|
||||
f"Area {area_id}" if area_id else "Hunter's Dream",
|
||||
)
|
||||
slots.append({
|
||||
"slot": i,
|
||||
"name": name_clean,
|
||||
"level": int(level),
|
||||
"playtime_secs": int(playtime_secs),
|
||||
"playtime_str": format_playtime(int(playtime_secs)),
|
||||
"playtime_clock": f"{h}:{m:02d}:{s:02d}",
|
||||
"echoes": int(echoes),
|
||||
"area_id": int(area_id),
|
||||
"location": location,
|
||||
})
|
||||
except Exception as ex:
|
||||
# Corrupted or incomplete file: fail gracefully
|
||||
return []
|
||||
return slots
|
||||
|
||||
|
||||
def parse_sfo(sfo_path: Path | str) -> dict[str, str]:
|
||||
"""Parse a PlayStation 4 param.sfo file and return string key-value pairs."""
|
||||
try:
|
||||
path = Path(sfo_path)
|
||||
if not path.is_file():
|
||||
return {}
|
||||
data = path.read_bytes()
|
||||
if len(data) < 20:
|
||||
return {}
|
||||
magic, ver, k_off, d_off, count = struct.unpack("<4sIIII", data[:20])
|
||||
if magic != b"\x00PSF":
|
||||
return {}
|
||||
entries = {}
|
||||
for i in range(count):
|
||||
e = 20 + i * 16
|
||||
k_pos, fmt, length, max_len, d_pos = struct.unpack("<HHIII", data[e:e + 16])
|
||||
k_end = data.find(b"\x00", k_off + k_pos)
|
||||
k = data[k_off + k_pos:k_end].decode("utf-8", "ignore")
|
||||
val = data[d_off + d_pos:d_off + d_pos + length]
|
||||
if b"\x00" in val:
|
||||
val = val[:val.find(b"\x00")]
|
||||
entries[k] = val.decode("utf-8", "ignore")
|
||||
return entries
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def get_game_save_title_id(base_dir: Path = None, eboot_path: Path | str = None) -> str:
|
||||
"""Determine the active title ID used for save data.
|
||||
|
||||
In Bloodborne, all editions (GOTY CUSA03173, Old Hunters CUSA03014, US CUSA00207,
|
||||
EU CUSA00900) map save data to CUSA00207 via INSTALL_DIR_SAVEDATA in param.sfo.
|
||||
"""
|
||||
candidates_sfo = []
|
||||
if eboot_path:
|
||||
ep = Path(eboot_path)
|
||||
candidates_sfo.extend([
|
||||
ep.parent / "sce_sys" / "param.sfo",
|
||||
ep.parent.parent / "sce_sys" / "param.sfo",
|
||||
])
|
||||
|
||||
settings_file = (base_dir or APP_DIR) / "bblauncher_settings.json"
|
||||
if settings_file.is_file():
|
||||
try:
|
||||
cfg = json.loads(settings_file.read_text(encoding="utf-8"))
|
||||
if cfg.get("eboot"):
|
||||
ep = Path(cfg["eboot"])
|
||||
candidates_sfo.extend([
|
||||
ep.parent / "sce_sys" / "param.sfo",
|
||||
ep.parent.parent / "sce_sys" / "param.sfo",
|
||||
])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if DEFAULT_GAME:
|
||||
candidates_sfo.append(DEFAULT_GAME / "sce_sys" / "param.sfo")
|
||||
|
||||
for sfo in candidates_sfo:
|
||||
if sfo.is_file():
|
||||
meta = parse_sfo(sfo)
|
||||
install_dir = meta.get("INSTALL_DIR_SAVEDATA", "").strip()
|
||||
if install_dir.startswith("CUSA"):
|
||||
return install_dir
|
||||
title_id = meta.get("TITLE_ID", "").strip()
|
||||
if title_id.startswith("CUSA"):
|
||||
return title_id
|
||||
|
||||
return "CUSA00207"
|
||||
|
||||
|
||||
def _find_sce_sys(sprj_dir: Path) -> Path | None:
|
||||
"""Find companion .sce_sys folder next to or inside a save folder."""
|
||||
check_paths = [
|
||||
sprj_dir.parent / f"{sprj_dir.name}.sce_sys",
|
||||
sprj_dir.parent / "SPRJ0005.sce_sys",
|
||||
sprj_dir / "sce_sys",
|
||||
sprj_dir.parent / "sce_sys",
|
||||
]
|
||||
for cp in check_paths:
|
||||
if cp.is_dir():
|
||||
return cp
|
||||
return None
|
||||
|
||||
|
||||
def get_save_data_root(base_dir: Path = None) -> Path:
|
||||
"""Return root directory where game save folders live (user/savedata)."""
|
||||
root = (base_dir or APP_DIR) / "user" / "savedata"
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
return root
|
||||
|
||||
|
||||
def get_save_backups_root(base_dir: Path = None) -> Path:
|
||||
"""Return root directory where backup folders live (user/savedata_backups)."""
|
||||
root = (base_dir or APP_DIR) / "user" / "savedata_backups"
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
return root
|
||||
|
||||
|
||||
def find_active_save_dir(base_dir: Path = None, eboot_path: Path | str = None) -> tuple[Path | None, Path | None]:
|
||||
"""Locate active SPRJ0005 folder and companion .sce_sys folder.
|
||||
|
||||
Prioritizes the canonical save directory matching the game's save title ID
|
||||
(user/savedata/<user_id>/<target_title_id>/SPRJ0005, e.g. CUSA00207).
|
||||
If a legacy or incorrect CUSA folder (such as CUSA03173) exists with save files while
|
||||
the canonical directory is empty, it automatically migrates the save data to CUSA00207.
|
||||
"""
|
||||
save_root = get_save_data_root(base_dir)
|
||||
target_title = get_game_save_title_id(base_dir, eboot_path)
|
||||
|
||||
# 1. First check canonical path: user/savedata/1/<target_title>/SPRJ0005
|
||||
canonical_sprj = save_root / "1" / target_title / "SPRJ0005"
|
||||
if canonical_sprj.is_dir() and (canonical_sprj / "userdata0010").is_file():
|
||||
return canonical_sprj, _find_sce_sys(canonical_sprj)
|
||||
|
||||
# Check across any user IDs for target_title (e.g. user/savedata/*/<target_title>/SPRJ0005)
|
||||
candidates = []
|
||||
try:
|
||||
for p in save_root.glob(f"*/{target_title}/SPRJ0005"):
|
||||
if p.is_dir() and (p / "userdata0010").is_file():
|
||||
candidates.append((p.stat().st_mtime, p))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if candidates:
|
||||
candidates.sort(key=lambda c: c[0], reverse=True)
|
||||
sprj_dir = candidates[0][1]
|
||||
return sprj_dir, _find_sce_sys(sprj_dir)
|
||||
|
||||
# 2. Check if a non-canonical CUSA save directory exists (e.g. CUSA03173 created by mistake)
|
||||
legacy_candidates = []
|
||||
try:
|
||||
for p in save_root.rglob("SPRJ0005"):
|
||||
if p.is_dir() and (p / "userdata0010").is_file():
|
||||
legacy_candidates.append((p.stat().st_mtime, p))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if legacy_candidates:
|
||||
legacy_candidates.sort(key=lambda c: c[0], reverse=True)
|
||||
old_sprj = legacy_candidates[0][1]
|
||||
old_title = old_sprj.parent.name
|
||||
# If the save was placed under an incorrect title ID (like CUSA03173 instead of CUSA00207),
|
||||
# migrate it to the canonical target_title directory
|
||||
if old_title != target_title:
|
||||
user_id = old_sprj.parent.parent.name if old_sprj.parent.parent != save_root else "1"
|
||||
new_sprj = save_root / user_id / target_title / "SPRJ0005"
|
||||
new_sprj.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
shutil.copytree(old_sprj, new_sprj, dirs_exist_ok=True)
|
||||
old_sce = _find_sce_sys(old_sprj)
|
||||
if old_sce and old_sce.is_dir():
|
||||
new_sce = new_sprj.parent / f"{new_sprj.name}.sce_sys"
|
||||
shutil.copytree(old_sce, new_sce, dirs_exist_ok=True)
|
||||
shutil.rmtree(old_sce, ignore_errors=True)
|
||||
shutil.rmtree(old_sprj.parent, ignore_errors=True)
|
||||
return new_sprj, _find_sce_sys(new_sprj)
|
||||
except OSError:
|
||||
return old_sprj, _find_sce_sys(old_sprj)
|
||||
return old_sprj, _find_sce_sys(old_sprj)
|
||||
|
||||
return None, None
|
||||
|
||||
|
||||
def get_active_save_summary(base_dir: Path = None, eboot_path: Path | str = None) -> dict:
|
||||
"""Return structured status and character slots for the currently active save."""
|
||||
sprj_dir, sce_sys_dir = find_active_save_dir(base_dir, eboot_path)
|
||||
if not sprj_dir:
|
||||
return {
|
||||
"found": False,
|
||||
"path": None,
|
||||
"rel_path": "No save file found",
|
||||
"characters": [],
|
||||
"mtime_str": "-",
|
||||
"size_str": "-",
|
||||
"file_count": 0,
|
||||
}
|
||||
|
||||
u10 = sprj_dir / "userdata0010"
|
||||
characters = parse_userdata0010(u10) if u10.is_file() else []
|
||||
|
||||
total_bytes = 0
|
||||
file_count = 0
|
||||
latest_mtime = 0
|
||||
try:
|
||||
for f in sprj_dir.iterdir():
|
||||
if f.is_file():
|
||||
st = f.stat()
|
||||
total_bytes += st.st_size
|
||||
file_count += 1
|
||||
if st.st_mtime > latest_mtime:
|
||||
latest_mtime = st.st_mtime
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
mtime_dt = datetime.fromtimestamp(latest_mtime) if latest_mtime > 0 else datetime.now()
|
||||
save_root = (base_dir or APP_DIR)
|
||||
try:
|
||||
rel_path = sprj_dir.relative_to(save_root)
|
||||
except ValueError:
|
||||
rel_path = sprj_dir
|
||||
|
||||
return {
|
||||
"found": True,
|
||||
"path": sprj_dir,
|
||||
"sce_sys_path": sce_sys_dir,
|
||||
"rel_path": str(rel_path),
|
||||
"characters": characters,
|
||||
"mtime": latest_mtime,
|
||||
"mtime_str": mtime_dt.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"size_bytes": total_bytes,
|
||||
"size_str": format_size(total_bytes),
|
||||
"file_count": file_count,
|
||||
}
|
||||
|
||||
|
||||
def list_save_backups(base_dir: Path = None) -> list[dict]:
|
||||
"""Enumerate all available backups from user/savedata_backups."""
|
||||
backups_root = get_save_backups_root(base_dir)
|
||||
results = []
|
||||
|
||||
try:
|
||||
for entry in backups_root.iterdir():
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
sprj_dir = entry / "SPRJ0005"
|
||||
if not sprj_dir.is_dir():
|
||||
# Allow flat backup directories if userdata0010 is directly in folder
|
||||
if (entry / "userdata0010").is_file():
|
||||
sprj_dir = entry
|
||||
else:
|
||||
continue
|
||||
|
||||
meta_file = entry / "meta.json"
|
||||
meta = {}
|
||||
if meta_file.is_file():
|
||||
try:
|
||||
meta = json.loads(meta_file.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
meta = {}
|
||||
|
||||
u10 = sprj_dir / "userdata0010"
|
||||
characters = meta.get("characters")
|
||||
if characters is None:
|
||||
characters = parse_userdata0010(u10) if u10.is_file() else []
|
||||
|
||||
# Compute folder size and file count
|
||||
total_bytes = 0
|
||||
file_count = 0
|
||||
latest_mtime = entry.stat().st_mtime
|
||||
try:
|
||||
for f in entry.rglob("*"):
|
||||
if f.is_file():
|
||||
st = f.stat()
|
||||
total_bytes += st.st_size
|
||||
file_count += 1
|
||||
if st.st_mtime > latest_mtime:
|
||||
latest_mtime = st.st_mtime
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
tag = meta.get("backup_type")
|
||||
if not tag:
|
||||
low_name = entry.name.lower()
|
||||
if "safepoint" in low_name:
|
||||
tag = "safepoint"
|
||||
elif "pre_launch" in low_name:
|
||||
tag = "pre_launch"
|
||||
elif "pre_restore" in low_name:
|
||||
tag = "pre_restore"
|
||||
elif "imported" in low_name:
|
||||
tag = "imported"
|
||||
else:
|
||||
tag = "manual"
|
||||
|
||||
name = meta.get("name")
|
||||
if not name:
|
||||
if tag == "safepoint":
|
||||
name = "Engine Auto-Safepoint"
|
||||
else:
|
||||
name = entry.name
|
||||
|
||||
ts_str = meta.get("timestamp")
|
||||
if not ts_str:
|
||||
ts_str = datetime.fromtimestamp(entry.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
results.append({
|
||||
"folder_name": entry.name,
|
||||
"folder_path": str(entry),
|
||||
"name": name,
|
||||
"backup_type": tag,
|
||||
"timestamp_str": ts_str,
|
||||
"mtime": latest_mtime,
|
||||
"characters": characters,
|
||||
"size_bytes": total_bytes,
|
||||
"size_str": format_size(total_bytes),
|
||||
"file_count": file_count,
|
||||
})
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
results.sort(key=lambda b: b["mtime"], reverse=True)
|
||||
return results
|
||||
|
||||
|
||||
def create_save_backup(base_dir: Path = None, label: str = "", backup_type: str = "manual", eboot_path: Path | str = None) -> dict | None:
|
||||
"""Create a full snapshot of the active save directory into user/savedata_backups."""
|
||||
sprj_dir, sce_sys_dir = find_active_save_dir(base_dir, eboot_path)
|
||||
if not sprj_dir or not sprj_dir.is_dir():
|
||||
return None
|
||||
|
||||
now = datetime.now()
|
||||
ts_slug = now.strftime("%Y-%m-%d_%H-%M-%S")
|
||||
clean_label = re.sub(r'[\\/*?:"<>| ]', "_", label.strip())
|
||||
folder_name = f"{ts_slug}_{backup_type}" + (f"_{clean_label}" if clean_label else "")
|
||||
|
||||
backups_root = get_save_backups_root(base_dir)
|
||||
target_dir = backups_root / folder_name
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
dst_sprj = target_dir / "SPRJ0005"
|
||||
shutil.copytree(sprj_dir, dst_sprj, dirs_exist_ok=True)
|
||||
|
||||
if sce_sys_dir and sce_sys_dir.is_dir():
|
||||
dst_sce = target_dir / "SPRJ0005.sce_sys"
|
||||
shutil.copytree(sce_sys_dir, dst_sce, dirs_exist_ok=True)
|
||||
|
||||
u10 = dst_sprj / "userdata0010"
|
||||
characters = parse_userdata0010(u10) if u10.is_file() else []
|
||||
|
||||
meta = {
|
||||
"name": label.strip() if label.strip() else f"Backup {ts_slug}",
|
||||
"timestamp": now.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"backup_type": backup_type,
|
||||
"characters": characters,
|
||||
"source_path": str(sprj_dir),
|
||||
}
|
||||
|
||||
try:
|
||||
(target_dir / "meta.json").write_text(json.dumps(meta, indent=2), encoding="utf-8")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return {
|
||||
"folder_name": folder_name,
|
||||
"folder_path": str(target_dir),
|
||||
"name": meta["name"],
|
||||
"backup_type": backup_type,
|
||||
"timestamp_str": meta["timestamp"],
|
||||
"characters": characters,
|
||||
}
|
||||
|
||||
|
||||
def restore_save_backup(backup_folder: Path | str, base_dir: Path = None, eboot_path: Path | str = None) -> bool:
|
||||
"""Restore save files from a backup directory into the active save location.
|
||||
|
||||
Takes a safety snapshot of the active save before applying any changes.
|
||||
Always restores to the canonical save directory (user/savedata/1/CUSA00207/SPRJ0005).
|
||||
"""
|
||||
bpath = Path(backup_folder)
|
||||
src_sprj = bpath / "SPRJ0005"
|
||||
if not src_sprj.is_dir():
|
||||
if (bpath / "userdata0010").is_file():
|
||||
src_sprj = bpath
|
||||
else:
|
||||
return False
|
||||
|
||||
target_title = get_game_save_title_id(base_dir, eboot_path)
|
||||
save_root = get_save_data_root(base_dir)
|
||||
|
||||
sprj_dir, sce_sys_dir = find_active_save_dir(base_dir, eboot_path)
|
||||
if not sprj_dir:
|
||||
sprj_dir = save_root / "1" / target_title / "SPRJ0005"
|
||||
sprj_dir.mkdir(parents=True, exist_ok=True)
|
||||
else:
|
||||
# Pre-restore safety snapshot so progress is never lost
|
||||
create_save_backup(base_dir, label="Pre-Restore Safety Snapshot", backup_type="pre_restore", eboot_path=eboot_path)
|
||||
# Ensure destination is the canonical target_title directory
|
||||
if sprj_dir.parent.name != target_title:
|
||||
user_id = sprj_dir.parent.parent.name if sprj_dir.parent.parent != save_root else "1"
|
||||
old_parent = sprj_dir.parent
|
||||
sprj_dir = save_root / user_id / target_title / "SPRJ0005"
|
||||
sprj_dir.mkdir(parents=True, exist_ok=True)
|
||||
shutil.rmtree(old_parent, ignore_errors=True)
|
||||
|
||||
# Clear active save files and copy backup in
|
||||
try:
|
||||
for f in sprj_dir.iterdir():
|
||||
if f.is_file():
|
||||
f.unlink(missing_ok=True)
|
||||
elif f.is_dir():
|
||||
shutil.rmtree(f, ignore_errors=True)
|
||||
for f in src_sprj.iterdir():
|
||||
if f.is_file():
|
||||
shutil.copy2(f, sprj_dir / f.name)
|
||||
|
||||
src_sce = bpath / "SPRJ0005.sce_sys"
|
||||
if not src_sce.is_dir() and (src_sprj / "sce_sys").is_dir():
|
||||
src_sce = src_sprj / "sce_sys"
|
||||
if not src_sce.is_dir() and (bpath / "sce_sys").is_dir():
|
||||
src_sce = bpath / "sce_sys"
|
||||
|
||||
if src_sce.is_dir():
|
||||
dst_sce = sprj_dir.parent / f"{sprj_dir.name}.sce_sys"
|
||||
dst_sce.mkdir(parents=True, exist_ok=True)
|
||||
for f in dst_sce.iterdir():
|
||||
if f.is_file():
|
||||
f.unlink(missing_ok=True)
|
||||
elif f.is_dir():
|
||||
shutil.rmtree(f, ignore_errors=True)
|
||||
for f in src_sce.iterdir():
|
||||
if f.is_file():
|
||||
shutil.copy2(f, dst_sce / f.name)
|
||||
return True
|
||||
except (OSError, shutil.Error):
|
||||
return False
|
||||
|
||||
|
||||
def delete_save_backup(backup_folder: Path | str) -> bool:
|
||||
"""Safely delete a backup directory."""
|
||||
try:
|
||||
p = Path(backup_folder)
|
||||
if p.is_dir():
|
||||
shutil.rmtree(p)
|
||||
return True
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def export_backup_to_zip(source_dir: Path | str, zip_dest: Path | str, companion_sce: Path | str = None) -> bool:
|
||||
"""Compress a save or backup directory into a clean .zip file."""
|
||||
try:
|
||||
src = Path(source_dir)
|
||||
dst = Path(zip_dest)
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
with zipfile.ZipFile(dst, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
if src.name == "SPRJ0005":
|
||||
for root, _, files in os.walk(src):
|
||||
for file in files:
|
||||
full_path = Path(root) / file
|
||||
arcname = Path("SPRJ0005") / full_path.relative_to(src)
|
||||
zf.write(full_path, arcname)
|
||||
sce = Path(companion_sce) if companion_sce else _find_sce_sys(src)
|
||||
if sce and sce.is_dir():
|
||||
for root, _, files in os.walk(sce):
|
||||
for file in files:
|
||||
full_path = Path(root) / file
|
||||
arcname = Path("SPRJ0005.sce_sys") / full_path.relative_to(sce)
|
||||
zf.write(full_path, arcname)
|
||||
else:
|
||||
for root, _, files in os.walk(src):
|
||||
for file in files:
|
||||
full_path = Path(root) / file
|
||||
arcname = full_path.relative_to(src)
|
||||
zf.write(full_path, arcname)
|
||||
return True
|
||||
except (OSError, zipfile.BadZipFile):
|
||||
return False
|
||||
|
||||
|
||||
def import_save_from_zip_or_dir(import_path: Path | str, base_dir: Path = None, eboot_path: Path | str = None) -> dict | None:
|
||||
"""Import a save from a .zip archive or an existing folder into user/savedata_backups."""
|
||||
src = Path(import_path)
|
||||
if not src.exists():
|
||||
return None
|
||||
|
||||
now = datetime.now()
|
||||
ts_slug = now.strftime("%Y-%m-%d_%H-%M-%S")
|
||||
clean_name = re.sub(r'[\\/*?:"<>| ]', "_", src.stem)
|
||||
target_folder = get_save_backups_root(base_dir) / f"{ts_slug}_imported_{clean_name}"
|
||||
target_sprj = target_folder / "SPRJ0005"
|
||||
|
||||
try:
|
||||
if src.is_file() and src.suffix.lower() == ".zip":
|
||||
# Extract to temporary folder first
|
||||
temp_extract = get_save_backups_root(base_dir) / f"_temp_{ts_slug}"
|
||||
temp_extract.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
with zipfile.ZipFile(src, "r") as zf:
|
||||
zf.extractall(temp_extract)
|
||||
|
||||
# Find SPRJ0005 or userdata0010 inside extracted archive
|
||||
found_sprj = None
|
||||
for p in temp_extract.rglob("*"):
|
||||
if p.is_dir() and p.name == "SPRJ0005" and (p / "userdata0010").is_file():
|
||||
found_sprj = p
|
||||
break
|
||||
if p.is_file() and p.name == "userdata0010":
|
||||
found_sprj = p.parent
|
||||
break
|
||||
|
||||
if not found_sprj:
|
||||
shutil.rmtree(temp_extract, ignore_errors=True)
|
||||
return None
|
||||
|
||||
target_folder.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copytree(found_sprj, target_sprj, dirs_exist_ok=True)
|
||||
|
||||
# Check if companion .sce_sys exists
|
||||
for cp in [
|
||||
found_sprj / "sce_sys",
|
||||
found_sprj.parent / f"{found_sprj.name}.sce_sys",
|
||||
found_sprj.parent / "SPRJ0005.sce_sys",
|
||||
found_sprj.parent / "sce_sys",
|
||||
]:
|
||||
if cp.is_dir():
|
||||
shutil.copytree(cp, target_folder / "SPRJ0005.sce_sys", dirs_exist_ok=True)
|
||||
break
|
||||
finally:
|
||||
shutil.rmtree(temp_extract, ignore_errors=True)
|
||||
elif src.is_dir():
|
||||
# Directory import
|
||||
found_sprj = None
|
||||
if (src / "userdata0010").is_file():
|
||||
found_sprj = src
|
||||
elif (src / "SPRJ0005" / "userdata0010").is_file():
|
||||
found_sprj = src / "SPRJ0005"
|
||||
|
||||
if not found_sprj:
|
||||
return None
|
||||
|
||||
target_folder.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copytree(found_sprj, target_sprj, dirs_exist_ok=True)
|
||||
|
||||
for cp in [
|
||||
found_sprj / "sce_sys",
|
||||
found_sprj.parent / f"{found_sprj.name}.sce_sys",
|
||||
found_sprj.parent / "SPRJ0005.sce_sys",
|
||||
found_sprj.parent / "sce_sys",
|
||||
]:
|
||||
if cp.is_dir():
|
||||
shutil.copytree(cp, target_folder / "SPRJ0005.sce_sys", dirs_exist_ok=True)
|
||||
break
|
||||
else:
|
||||
return None
|
||||
|
||||
u10 = target_sprj / "userdata0010"
|
||||
characters = parse_userdata0010(u10) if u10.is_file() else []
|
||||
|
||||
meta = {
|
||||
"name": f"Imported: {src.stem}",
|
||||
"timestamp": now.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"backup_type": "imported",
|
||||
"characters": characters,
|
||||
"source_import": str(src),
|
||||
}
|
||||
(target_folder / "meta.json").write_text(json.dumps(meta, indent=2), encoding="utf-8")
|
||||
|
||||
return {
|
||||
"folder_name": target_folder.name,
|
||||
"folder_path": str(target_folder),
|
||||
"name": meta["name"],
|
||||
"backup_type": "imported",
|
||||
"timestamp_str": meta["timestamp"],
|
||||
"characters": characters,
|
||||
}
|
||||
except Exception:
|
||||
if target_folder.exists():
|
||||
shutil.rmtree(target_folder, ignore_errors=True)
|
||||
return None
|
||||
|
||||
|
||||
def prune_rolling_backups(base_dir: Path = None, max_keep: int = 10) -> int:
|
||||
"""Retain only the newest max_keep pre_launch rolling backups."""
|
||||
if max_keep <= 0:
|
||||
return 0
|
||||
|
||||
backups = list_save_backups(base_dir)
|
||||
rolling = [b for b in backups if b.get("backup_type") == "pre_launch"]
|
||||
if len(rolling) <= max_keep:
|
||||
return 0
|
||||
|
||||
to_delete = rolling[max_keep:]
|
||||
count = 0
|
||||
for b in to_delete:
|
||||
if delete_save_backup(b["folder_path"]):
|
||||
count += 1
|
||||
return count
|
||||
+650
-34
@@ -23,12 +23,14 @@ import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from PySide6.QtCore import QEvent, QTimer, Qt
|
||||
from PySide6.QtGui import QIcon, QTextCursor
|
||||
from PySide6.QtGui import QIcon, QPixmap, QTextCursor
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QDialog,
|
||||
QFileDialog,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QInputDialog,
|
||||
QLabel,
|
||||
QMainWindow,
|
||||
QMessageBox,
|
||||
@@ -60,11 +62,26 @@ from bblauncher_Utils import (
|
||||
acquire_single_instance,
|
||||
build_stamp,
|
||||
connected_gpus,
|
||||
create_save_backup,
|
||||
delete_save_backup,
|
||||
display_name_to_scancode_label,
|
||||
export_backup_to_zip,
|
||||
find_active_save_dir,
|
||||
find_character_portrait,
|
||||
format_playtime,
|
||||
format_size,
|
||||
get_active_save_summary,
|
||||
get_bb_guard,
|
||||
get_game_save_title_id,
|
||||
get_save_backups_root,
|
||||
get_save_data_root,
|
||||
import_save_from_zip_or_dir,
|
||||
kill_tree,
|
||||
list_save_backups,
|
||||
load_xml_patches,
|
||||
prune_rolling_backups,
|
||||
resolve_game_dir,
|
||||
restore_save_backup,
|
||||
rotate_log,
|
||||
running_instances,
|
||||
verify_prelaunch,
|
||||
@@ -86,6 +103,24 @@ EFFECT_PATCH_MAP = {
|
||||
}
|
||||
|
||||
|
||||
def get_launcher_icon() -> QIcon:
|
||||
"""Find and return launcher QIcon checking PyInstaller frozen bundle and local paths."""
|
||||
candidates = []
|
||||
if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
|
||||
candidates.append(Path(sys._MEIPASS) / "icon.ico")
|
||||
candidates.extend([
|
||||
Path(__file__).resolve().parent / "icon.ico",
|
||||
APP_DIR / "icon.ico",
|
||||
APP_DIR / "bblauncherAPP" / "icon.ico",
|
||||
])
|
||||
for c in candidates:
|
||||
if c.is_file():
|
||||
ico = QIcon(str(c))
|
||||
if not ico.isNull():
|
||||
return ico
|
||||
return QIcon()
|
||||
|
||||
|
||||
class BloodborneLauncher(LauncherUI, QMainWindow):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
@@ -93,16 +128,21 @@ class BloodborneLauncher(LauncherUI, QMainWindow):
|
||||
self.setFixedSize(1200, 720)
|
||||
self.setStyleSheet(STYLE_SHEET)
|
||||
|
||||
# Deferred background warming for secondary tabs after window is already shown
|
||||
QTimer.singleShot(100, self._build_display_tab_now)
|
||||
QTimer.singleShot(300, self._build_patches_tab_now)
|
||||
QTimer.singleShot(500, self._build_mk_tab_now)
|
||||
QTimer.singleShot(700, self._build_advanced_tab_now)
|
||||
QTimer.singleShot(900, self._build_saves_tab_now)
|
||||
|
||||
app = QApplication.instance()
|
||||
if app is not None:
|
||||
self._wheel_guard = WheelGuard(self)
|
||||
app.installEventFilter(self._wheel_guard)
|
||||
|
||||
icon_file = APP_DIR / "icon.ico"
|
||||
if not icon_file.is_file():
|
||||
icon_file = Path(__file__).resolve().parent / "icon.ico"
|
||||
if icon_file.is_file():
|
||||
self.setWindowIcon(QIcon(str(icon_file)))
|
||||
launcher_icon = get_launcher_icon()
|
||||
if not launcher_icon.isNull():
|
||||
self.setWindowIcon(launcher_icon)
|
||||
|
||||
self.worker = None
|
||||
self.log_fh = None
|
||||
@@ -118,6 +158,7 @@ class BloodborneLauncher(LauncherUI, QMainWindow):
|
||||
self._display_built = False
|
||||
self._mk_built = False
|
||||
self._advanced_built = False
|
||||
self._saves_built = False
|
||||
self._syncing_ini = False
|
||||
self.all_xml_patches = []
|
||||
self.patch_checkboxes = {}
|
||||
@@ -149,10 +190,6 @@ class BloodborneLauncher(LauncherUI, QMainWindow):
|
||||
self.update_home_summary()
|
||||
QTimer.singleShot(250, self.refresh_setup_status)
|
||||
|
||||
# Deferred background warming for secondary tabs after window is already shown
|
||||
QTimer.singleShot(100, self._build_display_tab_now)
|
||||
QTimer.singleShot(300, self._build_patches_tab_now)
|
||||
|
||||
self._last_ini_mtime = 0
|
||||
try:
|
||||
ini_file = APP_DIR / "bbport.ini"
|
||||
@@ -166,6 +203,13 @@ class BloodborneLauncher(LauncherUI, QMainWindow):
|
||||
self.ini_sync_timer.start()
|
||||
self._suppress_settings_save = False
|
||||
|
||||
# Deferred background warming for secondary tabs after window is already shown
|
||||
QTimer.singleShot(100, self._build_display_tab_now)
|
||||
QTimer.singleShot(300, self._build_patches_tab_now)
|
||||
QTimer.singleShot(500, self._build_mk_tab_now)
|
||||
QTimer.singleShot(700, self._build_advanced_tab_now)
|
||||
QTimer.singleShot(900, self._build_saves_tab_now)
|
||||
|
||||
def showEvent(self, event):
|
||||
super().showEvent(event)
|
||||
if not getattr(self, "_dwm_applied", False):
|
||||
@@ -243,6 +287,507 @@ class BloodborneLauncher(LauncherUI, QMainWindow):
|
||||
finally:
|
||||
self._suppress_settings_save = previous
|
||||
|
||||
def _build_saves_tab_now(self):
|
||||
if getattr(self, "_saves_built", False):
|
||||
return
|
||||
self._saves_built = True
|
||||
previous = getattr(self, "_suppress_settings_save", False)
|
||||
self._suppress_settings_save = True
|
||||
try:
|
||||
self._install_tab("saves_placeholder", self.create_saves_tab())
|
||||
finally:
|
||||
self._suppress_settings_save = previous
|
||||
# Defer the file system scan to keep the UI responsive
|
||||
QTimer.singleShot(50, self.refresh_save_manager)
|
||||
|
||||
def _clear_layout(self, layout):
|
||||
if layout is None:
|
||||
return
|
||||
while layout.count():
|
||||
item = layout.takeAt(0)
|
||||
w = item.widget()
|
||||
if w is not None:
|
||||
w.deleteLater()
|
||||
elif item.layout() is not None:
|
||||
self._clear_layout(item.layout())
|
||||
|
||||
def _sync_save_auto_backup_to_ini(self, enabled: bool):
|
||||
val = "1" if enabled else "0"
|
||||
self._write_ini_values({"save_auto_backup": val})
|
||||
self.save_settings()
|
||||
|
||||
def refresh_save_manager(self):
|
||||
if not getattr(self, "_saves_built", False):
|
||||
return
|
||||
|
||||
# 1. Active Save Summary
|
||||
eboot = self.txt_eboot.text().strip() if hasattr(self, "txt_eboot") else ""
|
||||
active_summary = get_active_save_summary(APP_DIR, eboot_path=eboot)
|
||||
self._clear_layout(self.layout_active_characters)
|
||||
|
||||
if active_summary and active_summary.get("found"):
|
||||
title_text = f"Active Save: {active_summary['rel_path']}"
|
||||
self.lbl_active_save_path.setText(title_text)
|
||||
meta_text = (
|
||||
f"Last Modified: {active_summary['mtime_str']} | "
|
||||
f"Size: {active_summary['size_str']} ({active_summary['file_count']} files)"
|
||||
)
|
||||
self.lbl_active_save_meta.setText(meta_text)
|
||||
|
||||
characters = active_summary.get("characters", [])
|
||||
active_save_dir = active_summary.get("path")
|
||||
if characters:
|
||||
for char in characters:
|
||||
card = QFrame()
|
||||
card.setObjectName("CharSlotCard")
|
||||
card.setFixedHeight(84)
|
||||
card.setStyleSheet(
|
||||
"QFrame#CharSlotCard {"
|
||||
" background-color: #13151d;"
|
||||
" border: 1px solid #282a36;"
|
||||
" border-radius: 4px;"
|
||||
"}"
|
||||
"QFrame#CharSlotCard:hover {"
|
||||
" background-color: #171923;"
|
||||
" border: 1px solid #9c8b60;"
|
||||
"}"
|
||||
)
|
||||
h_layout = QHBoxLayout(card)
|
||||
h_layout.setContentsMargins(6, 6, 8, 6)
|
||||
h_layout.setSpacing(10)
|
||||
|
||||
# 1. Left: Character Portrait (Square 72x72)
|
||||
lbl_portrait = QLabel()
|
||||
lbl_portrait.setFixedSize(72, 72)
|
||||
lbl_portrait.setAlignment(Qt.AlignCenter)
|
||||
lbl_portrait.setStyleSheet(
|
||||
"background-color: #0b0c10;"
|
||||
"border: 1.5px solid #a39366;"
|
||||
"border-radius: 2px;"
|
||||
)
|
||||
|
||||
portrait_path = find_character_portrait(
|
||||
slot_idx=char["slot"],
|
||||
char_name=char["name"],
|
||||
save_dir=active_save_dir,
|
||||
base_dir=APP_DIR,
|
||||
)
|
||||
if portrait_path and portrait_path.is_file():
|
||||
pixmap = QPixmap(str(portrait_path))
|
||||
if not pixmap.isNull():
|
||||
lbl_portrait.setPixmap(
|
||||
pixmap.scaled(
|
||||
69, 69,
|
||||
Qt.KeepAspectRatioByExpanding,
|
||||
Qt.SmoothTransformation,
|
||||
)
|
||||
)
|
||||
lbl_portrait.setScaledContents(True)
|
||||
|
||||
h_layout.addWidget(lbl_portrait)
|
||||
|
||||
# 2. Right: Two-row details panel
|
||||
right_panel = QFrame()
|
||||
right_panel.setStyleSheet("background: transparent; border: none;")
|
||||
v_right = QVBoxLayout(right_panel)
|
||||
v_right.setContentsMargins(0, 0, 0, 0)
|
||||
v_right.setSpacing(0)
|
||||
|
||||
# --- Top Row (Header band) ---
|
||||
top_bar = QFrame()
|
||||
top_bar.setFixedHeight(34)
|
||||
top_bar.setStyleSheet(
|
||||
"background-color: #101219;"
|
||||
"border-radius: 2px;"
|
||||
"padding: 0 4px;"
|
||||
)
|
||||
top_lay = QHBoxLayout(top_bar)
|
||||
top_lay.setContentsMargins(8, 0, 8, 0)
|
||||
top_lay.setSpacing(12)
|
||||
|
||||
# Character Name (Warm ivory/gold serif font)
|
||||
lbl_name = QLabel(char["name"])
|
||||
lbl_name.setStyleSheet(
|
||||
"color: #e2d7ba;"
|
||||
"font-family: 'Cinzel', 'Garamond', 'Georgia', serif;"
|
||||
"font-size: 16px;"
|
||||
"font-weight: 700;"
|
||||
"background: transparent;"
|
||||
)
|
||||
top_lay.addWidget(lbl_name)
|
||||
top_lay.addStretch()
|
||||
|
||||
# Vertical separator 1
|
||||
sep1 = QFrame()
|
||||
sep1.setFixedSize(1, 18)
|
||||
sep1.setStyleSheet("background-color: #2e3140;")
|
||||
top_lay.addWidget(sep1)
|
||||
|
||||
# Level
|
||||
lbl_lvl = QLabel(f"Lvl: {char['level']}")
|
||||
lbl_lvl.setAlignment(Qt.AlignCenter)
|
||||
lbl_lvl.setStyleSheet(
|
||||
"color: #dcd2ba;"
|
||||
"font-family: 'Cinzel', 'Garamond', 'Georgia', serif;"
|
||||
"font-size: 14px;"
|
||||
"font-weight: 600;"
|
||||
"background: transparent;"
|
||||
)
|
||||
top_lay.addWidget(lbl_lvl)
|
||||
|
||||
# Vertical separator 2
|
||||
sep2 = QFrame()
|
||||
sep2.setFixedSize(1, 18)
|
||||
sep2.setStyleSheet("background-color: #2e3140;")
|
||||
top_lay.addWidget(sep2)
|
||||
|
||||
# Playtime (HH:MM:SS)
|
||||
playtime_val = char.get("playtime_clock", char.get("playtime_str", ""))
|
||||
lbl_time = QLabel(f"Playtime: {playtime_val}")
|
||||
lbl_time.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||||
lbl_time.setStyleSheet(
|
||||
"color: #dcd2ba;"
|
||||
"font-family: 'Cinzel', 'Garamond', 'Georgia', serif;"
|
||||
"font-size: 14px;"
|
||||
"font-weight: 600;"
|
||||
"background: transparent;"
|
||||
)
|
||||
top_lay.addWidget(lbl_time)
|
||||
|
||||
v_right.addWidget(top_bar)
|
||||
|
||||
# Hairline divider
|
||||
hline = QFrame()
|
||||
hline.setFixedHeight(1)
|
||||
hline.setStyleSheet("background-color: #282b37;")
|
||||
v_right.addWidget(hline)
|
||||
|
||||
# --- Bottom Row ---
|
||||
bot_bar = QFrame()
|
||||
bot_bar.setFixedHeight(34)
|
||||
bot_bar.setStyleSheet("background: transparent;")
|
||||
bot_lay = QHBoxLayout(bot_bar)
|
||||
bot_lay.setContentsMargins(8, 0, 8, 0)
|
||||
bot_lay.setSpacing(12)
|
||||
|
||||
# Blood Echoes on left
|
||||
lbl_echoes = QLabel(f"Blood Echoes: {char['echoes']:,}")
|
||||
lbl_echoes.setStyleSheet(
|
||||
"color: #c5a059;"
|
||||
"font-size: 11px;"
|
||||
"font-weight: 600;"
|
||||
"background: transparent;"
|
||||
)
|
||||
bot_lay.addWidget(lbl_echoes)
|
||||
bot_lay.addStretch()
|
||||
|
||||
# Location Name on right
|
||||
lbl_loc = QLabel(char.get("location", "Hunter's Dream"))
|
||||
lbl_loc.setStyleSheet(
|
||||
"color: #9c978b;"
|
||||
"font-family: 'Cinzel', 'Garamond', 'Georgia', serif;"
|
||||
"font-size: 13px;"
|
||||
"font-weight: 500;"
|
||||
"background: transparent;"
|
||||
)
|
||||
bot_lay.addWidget(lbl_loc)
|
||||
|
||||
v_right.addWidget(bot_bar)
|
||||
h_layout.addWidget(right_panel, 1)
|
||||
|
||||
self.layout_active_characters.addWidget(card)
|
||||
else:
|
||||
lbl_none = QLabel("No character data found (slot table uninitialized or empty).")
|
||||
lbl_none.setStyleSheet("color: #707080; font-size: 11px; font-style: italic;")
|
||||
self.layout_active_characters.addWidget(lbl_none)
|
||||
else:
|
||||
self.lbl_active_save_path.setText("No active save found")
|
||||
self.lbl_active_save_meta.setText("Game save directory user/savedata has not been initialized.")
|
||||
lbl_none = QLabel("Launch Bloodborne once to generate active save data.")
|
||||
lbl_none.setStyleSheet("color: #707080; font-size: 11px; font-style: italic;")
|
||||
self.layout_active_characters.addWidget(lbl_none)
|
||||
|
||||
# 2. Backups List
|
||||
backups = list_save_backups(APP_DIR)
|
||||
self.lbl_backup_count.setText(f"{len(backups)} Backup(s) available")
|
||||
self._clear_layout(self.layout_backups_list)
|
||||
|
||||
if not backups:
|
||||
self.lbl_no_backups.setVisible(True)
|
||||
return
|
||||
|
||||
self.lbl_no_backups.setVisible(False)
|
||||
|
||||
type_badges = {
|
||||
"manual": ("MANUAL", "#818cf8", "#1e1b4b"),
|
||||
"pre_launch": ("PRE-LAUNCH", "#34d399", "#064e3b"),
|
||||
"pre_restore": ("SAFETY SNAPSHOT", "#fbbf24", "#451a03"),
|
||||
"safepoint": ("SAFEPOINT", "#38bdf8", "#082f49"),
|
||||
"imported": ("IMPORTED", "#e879f9", "#4a044e"),
|
||||
}
|
||||
|
||||
for b in backups:
|
||||
card = QFrame()
|
||||
card.setStyleSheet(
|
||||
"QFrame { background: #141419; border: 1px solid #23232c; border-radius: 6px; padding: 6px;"
|
||||
" margin-bottom: 2px; }"
|
||||
"QFrame:hover { border-color: #353544; }"
|
||||
)
|
||||
v_box = QVBoxLayout(card)
|
||||
v_box.setContentsMargins(10, 8, 10, 8)
|
||||
v_box.setSpacing(8)
|
||||
|
||||
top_line = QHBoxLayout()
|
||||
top_line.setSpacing(8)
|
||||
|
||||
b_type = b.get("backup_type", "manual")
|
||||
badge_text, badge_fg, badge_bg = type_badges.get(b_type, ("BACKUP", "#9ca3af", "#1f2937"))
|
||||
lbl_badge = QLabel(f" {badge_text} ")
|
||||
lbl_badge.setStyleSheet(
|
||||
f"color: {badge_fg}; background: {badge_bg}; border: 1px solid {badge_fg}44; "
|
||||
f"border-radius: 3px; font-size: 9px; font-weight: 700; padding: 1px 4px;"
|
||||
)
|
||||
top_line.addWidget(lbl_badge)
|
||||
|
||||
lbl_label = QLabel(b.get("name") or b.get("folder_name", "Backup"))
|
||||
lbl_label.setStyleSheet("color: #ffffff; font-size: 13px; font-weight: 600;")
|
||||
top_line.addWidget(lbl_label)
|
||||
|
||||
lbl_time = QLabel(b.get("timestamp_str", ""))
|
||||
lbl_time.setStyleSheet("color: #8c8c9e; font-size: 11px;")
|
||||
top_line.addWidget(lbl_time)
|
||||
|
||||
lbl_size = QLabel(f"({b.get('size_str', '')})")
|
||||
lbl_size.setStyleSheet("color: #707080; font-size: 10px;")
|
||||
top_line.addWidget(lbl_size)
|
||||
|
||||
top_line.addStretch()
|
||||
|
||||
btn_restore = QPushButton("Restore")
|
||||
btn_restore.setObjectName("BackupRestoreBtn")
|
||||
btn_restore.setFixedHeight(28)
|
||||
btn_restore.setCursor(Qt.PointingHandCursor)
|
||||
b_path = b["folder_path"]
|
||||
btn_restore.clicked.connect(lambda checked=False, p=b_path: self.handle_restore_backup(p))
|
||||
top_line.addWidget(btn_restore)
|
||||
|
||||
btn_export = QPushButton("Export ZIP")
|
||||
btn_export.setObjectName("BackupActionBtn")
|
||||
btn_export.setFixedHeight(28)
|
||||
btn_export.setCursor(Qt.PointingHandCursor)
|
||||
btn_export.clicked.connect(lambda checked=False, p=b_path: self.handle_export_backup(p))
|
||||
top_line.addWidget(btn_export)
|
||||
|
||||
btn_del = QPushButton("Delete")
|
||||
btn_del.setObjectName("BackupDeleteBtn")
|
||||
btn_del.setFixedHeight(28)
|
||||
btn_del.setCursor(Qt.PointingHandCursor)
|
||||
btn_del.clicked.connect(lambda checked=False, p=b_path: self.handle_delete_backup(p))
|
||||
top_line.addWidget(btn_del)
|
||||
|
||||
v_box.addLayout(top_line)
|
||||
|
||||
chars = b.get("characters", [])
|
||||
if chars:
|
||||
char_flow = QHBoxLayout()
|
||||
char_flow.setSpacing(8)
|
||||
for c in chars:
|
||||
p_clock = c.get("playtime_clock", c.get("playtime_str", ""))
|
||||
loc = c.get("location", "")
|
||||
chip_text = f"<b>{c['name']}</b> (Lv {c['level']} · {p_clock}" + (f" · {loc})" if loc else ")")
|
||||
lbl_c = QLabel(chip_text)
|
||||
lbl_c.setTextFormat(Qt.RichText)
|
||||
lbl_c.setStyleSheet(
|
||||
"color: #cfc7b4; background-color: #171922; border: 1px solid #292c3a; "
|
||||
"border-radius: 4px; padding: 3px 8px; font-size: 11px;"
|
||||
)
|
||||
char_flow.addWidget(lbl_c)
|
||||
char_flow.addStretch()
|
||||
v_box.addLayout(char_flow)
|
||||
|
||||
self.layout_backups_list.addWidget(card)
|
||||
|
||||
def handle_create_backup(self):
|
||||
eboot = self.txt_eboot.text().strip() if hasattr(self, "txt_eboot") else ""
|
||||
active_save, _ = find_active_save_dir(APP_DIR, eboot_path=eboot)
|
||||
if not active_save:
|
||||
QMessageBox.warning(self, "Backup Save", "No active save data found to backup.")
|
||||
return
|
||||
|
||||
label, ok = QInputDialog.getText(
|
||||
self,
|
||||
"Create Backup",
|
||||
"Enter a description or label for this backup:",
|
||||
text=f"Manual Backup {datetime.now():%b %d %H:%M}",
|
||||
)
|
||||
if not ok or not label:
|
||||
return
|
||||
|
||||
label = label.strip()
|
||||
backup_info = create_save_backup(base_dir=APP_DIR, label=label, backup_type="manual", eboot_path=eboot)
|
||||
if backup_info:
|
||||
self.log(f"[SAVE MANAGER] Created manual backup '{label}' in: {backup_info['folder_name']}")
|
||||
self.refresh_save_manager()
|
||||
else:
|
||||
QMessageBox.critical(self, "Backup Failed", "Failed to create save backup. Check launcher logs.")
|
||||
|
||||
def handle_restore_backup(self, folder_path, confirm: bool = True):
|
||||
if self.worker and self.worker.isRunning():
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Restore Save",
|
||||
"Cannot restore save data while Bloodborne is currently running. Please stop the game first.",
|
||||
)
|
||||
return
|
||||
|
||||
if confirm:
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"Restore Save Confirmation",
|
||||
"Are you sure you want to restore this backup?\n\n"
|
||||
"A safety snapshot of your current active save will be created automatically before restoring.",
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
)
|
||||
if reply != QMessageBox.Yes:
|
||||
return
|
||||
|
||||
eboot = self.txt_eboot.text().strip() if hasattr(self, "txt_eboot") else ""
|
||||
success = restore_save_backup(folder_path, base_dir=APP_DIR, eboot_path=eboot)
|
||||
if success:
|
||||
msg = f"Save backup restored successfully from: {Path(folder_path).name}"
|
||||
self.log(f"[SAVE MANAGER] {msg}")
|
||||
QMessageBox.information(self, "Save Restored", msg)
|
||||
else:
|
||||
msg = f"Failed to restore backup from: {Path(folder_path).name}"
|
||||
self.log(f"[SAVE MANAGER ERROR] {msg}")
|
||||
QMessageBox.critical(self, "Restore Failed", msg)
|
||||
self.refresh_save_manager()
|
||||
|
||||
def handle_delete_backup(self, folder_path):
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"Delete Backup",
|
||||
"Are you sure you want to permanently delete this save backup?\n\nThis action cannot be undone.",
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
)
|
||||
if reply != QMessageBox.Yes:
|
||||
return
|
||||
|
||||
success = delete_save_backup(folder_path)
|
||||
if success:
|
||||
self.log(f"[SAVE MANAGER] Deleted backup: {Path(folder_path).name}")
|
||||
else:
|
||||
self.log(f"[SAVE MANAGER ERROR] Could not delete backup: {Path(folder_path).name}")
|
||||
QMessageBox.critical(self, "Delete Failed", f"Could not delete backup directory:\n{folder_path}")
|
||||
self.refresh_save_manager()
|
||||
|
||||
def handle_open_saves_folder(self):
|
||||
eboot = self.txt_eboot.text().strip() if hasattr(self, "txt_eboot") else ""
|
||||
active_save, _ = find_active_save_dir(APP_DIR, eboot_path=eboot)
|
||||
if active_save and active_save.parent.is_dir():
|
||||
target = active_save.parent
|
||||
else:
|
||||
target_title = get_game_save_title_id(APP_DIR, eboot)
|
||||
target = get_save_data_root(APP_DIR) / "1" / target_title
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
if hasattr(os, "startfile"):
|
||||
os.startfile(str(target))
|
||||
else:
|
||||
subprocess.Popen(["explorer", str(target)])
|
||||
except Exception:
|
||||
try:
|
||||
subprocess.Popen(["explorer", str(target)])
|
||||
except Exception as e:
|
||||
self.log(f"[SAVE MANAGER ERROR] Could not open save folder: {e}")
|
||||
|
||||
def handle_export_active_save(self):
|
||||
eboot = self.txt_eboot.text().strip() if hasattr(self, "txt_eboot") else ""
|
||||
active_save, sce_sys = find_active_save_dir(APP_DIR, eboot_path=eboot)
|
||||
if not active_save:
|
||||
QMessageBox.warning(self, "Export Save", "No active save data found to export.")
|
||||
return
|
||||
|
||||
default_zip_name = f"bloodborne_save_{datetime.now():%Y%m%d_%H%M%S}.zip"
|
||||
file_path, _ = QFileDialog.getSaveFileName(
|
||||
self,
|
||||
"Export Active Save Data as ZIP",
|
||||
str(APP_DIR / default_zip_name),
|
||||
"ZIP Archives (*.zip)",
|
||||
)
|
||||
if not file_path:
|
||||
return
|
||||
|
||||
success = export_backup_to_zip(active_save, Path(file_path), companion_sce=sce_sys)
|
||||
if success:
|
||||
self.log(f"[SAVE MANAGER] Exported active save to: {file_path}")
|
||||
QMessageBox.information(self, "Export Complete", f"Save data exported successfully to:\n{file_path}")
|
||||
else:
|
||||
self.log(f"[SAVE MANAGER ERROR] Failed to export active save to {file_path}")
|
||||
QMessageBox.critical(self, "Export Failed", f"Failed to compress active save to:\n{file_path}")
|
||||
|
||||
def handle_export_backup(self, folder_path):
|
||||
p = Path(folder_path)
|
||||
default_zip_name = f"{p.name}.zip"
|
||||
file_path, _ = QFileDialog.getSaveFileName(
|
||||
self,
|
||||
"Export Save Backup as ZIP",
|
||||
str(APP_DIR / default_zip_name),
|
||||
"ZIP Archives (*.zip)",
|
||||
)
|
||||
if not file_path:
|
||||
return
|
||||
|
||||
success = export_backup_to_zip(p, Path(file_path))
|
||||
if success:
|
||||
self.log(f"[SAVE MANAGER] Exported backup to: {file_path}")
|
||||
QMessageBox.information(self, "Export Complete", f"Backup exported successfully to:\n{file_path}")
|
||||
else:
|
||||
self.log(f"[SAVE MANAGER ERROR] Failed to export backup to {file_path}")
|
||||
QMessageBox.critical(self, "Export Failed", f"Failed to compress backup to:\n{file_path}")
|
||||
|
||||
def handle_import_save(self):
|
||||
if self.worker and self.worker.isRunning():
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Import Save",
|
||||
"Cannot import save data while Bloodborne is currently running. Please stop the game first.",
|
||||
)
|
||||
return
|
||||
|
||||
file_path, _ = QFileDialog.getOpenFileName(
|
||||
self,
|
||||
"Import Save Archive",
|
||||
str(APP_DIR),
|
||||
"Supported Archives (*.zip);;All Files (*.*)",
|
||||
)
|
||||
if not file_path:
|
||||
return
|
||||
|
||||
src_path = Path(file_path)
|
||||
eboot = self.txt_eboot.text().strip() if hasattr(self, "txt_eboot") else ""
|
||||
imported_info = import_save_from_zip_or_dir(src_path, base_dir=APP_DIR, eboot_path=eboot)
|
||||
if not imported_info:
|
||||
QMessageBox.critical(
|
||||
self,
|
||||
"Import Failed",
|
||||
"Failed to import save. Verify that the ZIP archive contains userdata0010 or a valid save directory structure.",
|
||||
)
|
||||
return
|
||||
|
||||
self.log(f"[SAVE MANAGER] Imported save to backups: {imported_info['folder_name']}")
|
||||
self.refresh_save_manager()
|
||||
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"Import Successful",
|
||||
f"Save successfully imported into backups:\n{imported_info['folder_name']}\n\n"
|
||||
"Would you like to restore this imported save to your active game save right now?",
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
)
|
||||
if reply == QMessageBox.Yes:
|
||||
self.handle_restore_backup(imported_info["folder_path"], confirm=False)
|
||||
|
||||
def load_settings(self) -> dict:
|
||||
merged = dict(DEFAULT_SETTINGS)
|
||||
try:
|
||||
@@ -279,7 +824,8 @@ class BloodborneLauncher(LauncherUI, QMainWindow):
|
||||
"gpu": self.cmb_gpu.currentData() or "",
|
||||
"language": self.cmb_lang.currentData() or "1",
|
||||
"async_pipelines": self.cmb_async_pipelines.currentData() or "auto",
|
||||
"feat_upscaler": self.chk_fsr.isChecked() and upscaler_choice != "Off",
|
||||
"texture_quality": self.cmb_texture_quality.currentData() or "auto",
|
||||
"feat_upscaler": upscaler_choice != "Off",
|
||||
"upscaler_model": upscaler_choice,
|
||||
"feat_overlay": True,
|
||||
"feat_fps_patch": self.chk_fps_patch.isChecked(),
|
||||
@@ -305,6 +851,13 @@ class BloodborneLauncher(LauncherUI, QMainWindow):
|
||||
else:
|
||||
cfg["mods"] = bool(cfg.get("mods", False))
|
||||
|
||||
if getattr(self, "_saves_built", False):
|
||||
cfg.update({
|
||||
"auto_backup_on_launch": self.chk_auto_backup_launch.isChecked(),
|
||||
"max_rolling_backups": self.cmb_max_backups.currentData() or 10,
|
||||
"save_auto_backup": self.chk_engine_safepoint.isChecked(),
|
||||
})
|
||||
|
||||
return cfg
|
||||
|
||||
def save_settings(self):
|
||||
@@ -339,8 +892,8 @@ class BloodborneLauncher(LauncherUI, QMainWindow):
|
||||
self._setup_timer.stop()
|
||||
self._set_setup_display(
|
||||
"error",
|
||||
"Path does not contain an eboot.bin.",
|
||||
f"Selected path:\n{target}\n\nNo eboot.bin was found in that folder.",
|
||||
"Path does not contain an eboot.bin or eboot.elf.",
|
||||
f"Selected path:\n{target}\n\nNo eboot.bin or eboot.elf was found in that folder.",
|
||||
)
|
||||
self._set_setup_checks("fail", "unknown", "unknown")
|
||||
return
|
||||
@@ -371,14 +924,14 @@ class BloodborneLauncher(LauncherUI, QMainWindow):
|
||||
self.lbl_path_status.setStyleSheet(
|
||||
"color: #f0b429; font-size: 14px; font-weight: 700; background: transparent;"
|
||||
)
|
||||
self.lbl_path_hint.setText("No eboot.bin found at this path. Drag eboot.bin or the game folder here.")
|
||||
self.lbl_path_hint.setText("No eboot.bin or eboot.elf found at this path. Drag eboot.bin/eboot.elf or the game folder here.")
|
||||
self.lbl_path_hint.setToolTip(target)
|
||||
else:
|
||||
self.lbl_path_status.setText("\u2022")
|
||||
self.lbl_path_status.setStyleSheet(
|
||||
"color: #6a6a78; font-size: 14px; font-weight: 700; background: transparent;"
|
||||
)
|
||||
self.lbl_path_hint.setText("Select eboot.bin or drag the game folder here.")
|
||||
self.lbl_path_hint.setText("Select eboot.bin / eboot.elf or drag the game folder here.")
|
||||
self.lbl_path_hint.setToolTip("")
|
||||
|
||||
if game_dir is not None:
|
||||
@@ -482,8 +1035,8 @@ class BloodborneLauncher(LauncherUI, QMainWindow):
|
||||
if not game_dir:
|
||||
self._set_setup_display(
|
||||
"error",
|
||||
"Path does not contain an eboot.bin.",
|
||||
f"Selected path:\n{target}\n\nNo eboot.bin was found in that folder.",
|
||||
"Path does not contain an eboot.bin or eboot.elf.",
|
||||
f"Selected path:\n{target}\n\nNo eboot.bin or eboot.elf was found in that folder.",
|
||||
)
|
||||
self._set_setup_checks("fail", "unknown", "unknown")
|
||||
return
|
||||
@@ -510,7 +1063,7 @@ class BloodborneLauncher(LauncherUI, QMainWindow):
|
||||
|
||||
self._set_setup_display(kind, summary, details_text)
|
||||
|
||||
if "Game File Verification Failed" in str(err or "") or "Missing eboot.bin" in str(err or ""):
|
||||
if "Game File Verification Failed" in str(err or "") or "Missing eboot" in str(err or ""):
|
||||
game_state = "fail"
|
||||
elif any(("[OK] Game executable" in line or "[OK] Game Title" in line) for line in detail_lines):
|
||||
game_state = "ok"
|
||||
@@ -588,7 +1141,7 @@ class BloodborneLauncher(LauncherUI, QMainWindow):
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"Game Folder",
|
||||
"Select a valid eboot.bin or game folder first.",
|
||||
"Select a valid eboot.bin, eboot.elf or game folder first.",
|
||||
)
|
||||
return
|
||||
try:
|
||||
@@ -976,7 +1529,7 @@ class BloodborneLauncher(LauncherUI, QMainWindow):
|
||||
|
||||
aa_choice = self.cmb_aa.currentText()
|
||||
upscaler_choice = self.cmb_upscaler.currentText()
|
||||
is_fsr = self.chk_fsr.isChecked() and upscaler_choice != "Off"
|
||||
is_fsr = upscaler_choice != "Off"
|
||||
is_taa = "TAA" in aa_choice
|
||||
is_upscaler_active = is_fsr or is_taa
|
||||
effect_game_aa = "0" if is_upscaler_active or "Disable" in aa_choice else "1"
|
||||
@@ -1004,6 +1557,7 @@ class BloodborneLauncher(LauncherUI, QMainWindow):
|
||||
"effect_game_aa": effect_game_aa,
|
||||
"prep_workers": prep_workers_val,
|
||||
"async_pipelines": self.cmb_async_pipelines.currentData() or "auto",
|
||||
"texture_quality": self.cmb_texture_quality.currentData() or "auto",
|
||||
"gpu": self.cmb_gpu.currentData() or "",
|
||||
"live_resolution": self.cmb_live_res.currentData() or "0",
|
||||
"sharpen": "1" if self.chk_sharpen.isChecked() else "0",
|
||||
@@ -1048,6 +1602,9 @@ class BloodborneLauncher(LauncherUI, QMainWindow):
|
||||
if self._advanced_built:
|
||||
keys_to_update["language"] = self.cmb_menu_lang.currentData() or "en"
|
||||
|
||||
if getattr(self, "_saves_built", False) and hasattr(self, "chk_engine_safepoint"):
|
||||
keys_to_update["save_auto_backup"] = "1" if self.chk_engine_safepoint.isChecked() else "0"
|
||||
|
||||
if not keys_to_update:
|
||||
return
|
||||
if self._write_ini_values(keys_to_update):
|
||||
@@ -1139,8 +1696,8 @@ class BloodborneLauncher(LauncherUI, QMainWindow):
|
||||
"key.move_backward", "key.move_right"):
|
||||
btn = self.keybind_buttons.get(action_key)
|
||||
if btn is not None:
|
||||
# The preset holds printed labels. Convert each one to the physical
|
||||
# SDL scancode name for this keyboard layout before saving.
|
||||
# The preset holds printed labels. Convert each one to the physical
|
||||
# SDL scancode name expected by the C engine before saving.
|
||||
stored = display_name_to_scancode_label(preset[action_key])
|
||||
self._assign_keybind(action_key, stored, btn.current_binding)
|
||||
self.save_bbport_ini()
|
||||
@@ -1296,10 +1853,10 @@ class BloodborneLauncher(LauncherUI, QMainWindow):
|
||||
else:
|
||||
upscaler_value = "off"
|
||||
self._set_combo_data(self.cmb_upscaler, upscaler_value)
|
||||
self._set_check(self.chk_fsr, upscaler_value != "off")
|
||||
self._set_combo_data(self.cmb_preset, ini_opts.get("preset", "1"))
|
||||
self._set_combo_data(self.cmb_live_res, ini_opts.get("live_resolution", "0").strip().lower())
|
||||
self._set_combo_data(self.cmb_async_pipelines, ini_opts.get("async_pipelines", "auto").strip().lower())
|
||||
self._set_combo_data(self.cmb_texture_quality, ini_opts.get("texture_quality", "auto").strip().lower())
|
||||
self._set_combo_data(self.cmb_hud_quadrant, ini_opts.get("hud_quadrant", "0"))
|
||||
self._set_combo_data(self.cmb_debug_view, ini_opts.get("debug_view", "0"))
|
||||
self._set_combo_data(self.cmb_model_lod, ini_opts.get("model_lod", "0"))
|
||||
@@ -1399,7 +1956,7 @@ class BloodborneLauncher(LauncherUI, QMainWindow):
|
||||
|
||||
def sync_bbport_ini_to_ui(self):
|
||||
self._sync_external_patch_settings()
|
||||
if not (self._display_built or self._mk_built or self._advanced_built or self._patches_built):
|
||||
if not (self._display_built or self._mk_built or self._advanced_built or self._patches_built or getattr(self, "_saves_built", False)):
|
||||
return
|
||||
ini_path = APP_DIR / "bbport.ini"
|
||||
if not ini_path.is_file():
|
||||
@@ -1420,6 +1977,11 @@ class BloodborneLauncher(LauncherUI, QMainWindow):
|
||||
self._set_combo_data(self.cmb_menu_lang, ini_opts.get("language", "en").strip().lower())
|
||||
if self._patches_built:
|
||||
self._sync_patches_from_ini(ini_opts, persist=True)
|
||||
if getattr(self, "_saves_built", False) and hasattr(self, "chk_engine_safepoint"):
|
||||
val = ini_opts.get("save_auto_backup", "1").strip().lower() in ("1", "true")
|
||||
self.chk_engine_safepoint.blockSignals(True)
|
||||
self.chk_engine_safepoint.setChecked(val)
|
||||
self.chk_engine_safepoint.blockSignals(False)
|
||||
finally:
|
||||
self._syncing_ini = False
|
||||
except Exception:
|
||||
@@ -1436,6 +1998,9 @@ class BloodborneLauncher(LauncherUI, QMainWindow):
|
||||
cb.setChecked(False)
|
||||
cb.setEnabled(False)
|
||||
cb.setToolTip("Disabled: Conflicts with Live FPS Patching.")
|
||||
warn_lbl = getattr(self, "patch_warnings", {}).get(name)
|
||||
if warn_lbl:
|
||||
warn_lbl.setVisible(True)
|
||||
cb.blockSignals(False)
|
||||
self.enabled_patches.discard(name)
|
||||
self.update_patch_count_label()
|
||||
@@ -1474,14 +2039,19 @@ class BloodborneLauncher(LauncherUI, QMainWindow):
|
||||
for name, cb in self.patch_checkboxes.items():
|
||||
if name in LIVE_FPS_CONFLICTING_PATCHES:
|
||||
cb.blockSignals(True)
|
||||
warn_lbl = getattr(self, "patch_warnings", {}).get(name)
|
||||
if is_live:
|
||||
cb.setChecked(False)
|
||||
cb.setEnabled(False)
|
||||
cb.setToolTip("Disabled: Conflicts with Live FPS Patching.")
|
||||
if warn_lbl:
|
||||
warn_lbl.setVisible(True)
|
||||
else:
|
||||
cb.setEnabled(True)
|
||||
cb.setChecked(name in self.enabled_patches)
|
||||
cb.setToolTip("")
|
||||
if warn_lbl:
|
||||
warn_lbl.setVisible(False)
|
||||
cb.blockSignals(False)
|
||||
self.update_patch_count_label()
|
||||
if changed:
|
||||
@@ -1493,14 +2063,19 @@ class BloodborneLauncher(LauncherUI, QMainWindow):
|
||||
is_live = hasattr(self, "chk_fps_patch") and self.chk_fps_patch.isChecked()
|
||||
for name, cb in self.patch_checkboxes.items():
|
||||
cb.blockSignals(True)
|
||||
warn_lbl = getattr(self, "patch_warnings", {}).get(name)
|
||||
if is_live and name in LIVE_FPS_CONFLICTING_PATCHES:
|
||||
cb.setChecked(False)
|
||||
cb.setEnabled(False)
|
||||
cb.setToolTip("Disabled: Conflicts with Live FPS Patching.")
|
||||
if warn_lbl:
|
||||
warn_lbl.setVisible(True)
|
||||
else:
|
||||
cb.setEnabled(True)
|
||||
cb.setChecked(name in self.enabled_patches)
|
||||
cb.setToolTip("")
|
||||
if warn_lbl:
|
||||
warn_lbl.setVisible(False)
|
||||
cb.blockSignals(False)
|
||||
self.update_patch_count_label()
|
||||
|
||||
@@ -1565,7 +2140,7 @@ class BloodborneLauncher(LauncherUI, QMainWindow):
|
||||
def browse_eboot(self):
|
||||
start = self.txt_eboot.text().strip() or str(DEFAULT_GAME if DEFAULT_GAME.exists() else APP_DIR)
|
||||
path, _ = QFileDialog.getOpenFileName(
|
||||
self, "Select Bloodborne eboot.bin", start, "PS4 Executable (*.bin);;All Files (*)"
|
||||
self, "Select Bloodborne Executable", start, "PS4 Executables (*.bin *.elf);;All Files (*)"
|
||||
)
|
||||
if path:
|
||||
self.txt_eboot.setText(os.path.normpath(path))
|
||||
@@ -1574,12 +2149,12 @@ class BloodborneLauncher(LauncherUI, QMainWindow):
|
||||
def manual_verify_setup(self):
|
||||
target = self.txt_eboot.text().strip()
|
||||
if not target:
|
||||
QMessageBox.critical(self, "Error", "Specify the path to eboot.bin first.")
|
||||
QMessageBox.critical(self, "Error", "Specify the path to eboot.bin or eboot.elf first.")
|
||||
return
|
||||
|
||||
game_dir = resolve_game_dir(target)
|
||||
if not game_dir:
|
||||
QMessageBox.critical(self, "Invalid Path", f"Could not find eboot.bin in:\n{target}")
|
||||
QMessageBox.critical(self, "Invalid Path", f"Could not find eboot.bin or eboot.elf in:\n{target}")
|
||||
return
|
||||
|
||||
self.log("--- Running Pre-Launch Verification Check ---")
|
||||
@@ -1664,14 +2239,14 @@ class BloodborneLauncher(LauncherUI, QMainWindow):
|
||||
target = self.txt_eboot.text().strip()
|
||||
if not target:
|
||||
self.update_path_state()
|
||||
self.log("[PRE-LAUNCH] Select eboot.bin or the game folder first.")
|
||||
self.log("[PRE-LAUNCH] Select eboot.bin / eboot.elf or the game folder first.")
|
||||
self.txt_eboot.setFocus()
|
||||
return
|
||||
|
||||
game_dir = resolve_game_dir(target)
|
||||
if not game_dir:
|
||||
self.update_path_state()
|
||||
self.log(f"[PRE-LAUNCH] eboot.bin not found in: {target}")
|
||||
self.log(f"[PRE-LAUNCH] eboot.bin or eboot.elf not found in: {target}")
|
||||
self.txt_eboot.setFocus()
|
||||
return
|
||||
|
||||
@@ -1777,6 +2352,12 @@ class BloodborneLauncher(LauncherUI, QMainWindow):
|
||||
if lang_code:
|
||||
env["BB_LANGUAGE"] = str(lang_code)
|
||||
|
||||
tex_quality = str(cfg.get("texture_quality", "auto")).strip().lower()
|
||||
if tex_quality:
|
||||
env["BB_TEXTURE_QUALITY"] = tex_quality
|
||||
else:
|
||||
env.pop("BB_TEXTURE_QUALITY", None)
|
||||
|
||||
for cfg_key, env_key in (
|
||||
("timeout", "BB_TIMEOUT"),
|
||||
("dmem_mb", "BB_DMEM_MB"),
|
||||
@@ -1815,6 +2396,31 @@ class BloodborneLauncher(LauncherUI, QMainWindow):
|
||||
else:
|
||||
env.pop("BB_PATCHES", None)
|
||||
|
||||
# Save Game auto-backup before game launch
|
||||
if cfg.get("auto_backup_on_launch", True):
|
||||
try:
|
||||
eboot_target = cfg.get("eboot", "")
|
||||
active_save, _ = find_active_save_dir(APP_DIR, eboot_path=eboot_target)
|
||||
if active_save:
|
||||
backup_info = create_save_backup(
|
||||
base_dir=APP_DIR,
|
||||
label="Pre-Launch Session",
|
||||
backup_type="pre_launch",
|
||||
eboot_path=eboot_target,
|
||||
)
|
||||
if backup_info:
|
||||
self.log(f"[SAVE MANAGER] Created pre-launch backup: {backup_info['folder_name']}")
|
||||
max_backups = int(cfg.get("max_rolling_backups", 10))
|
||||
pruned = prune_rolling_backups(max_keep=max_backups, base_dir=APP_DIR)
|
||||
if pruned > 0:
|
||||
self.log(f"[SAVE MANAGER] Pruned {pruned} old rolling backup(s).")
|
||||
except Exception as e:
|
||||
self.log(f"[SAVE MANAGER] Warning: Pre-launch auto-backup encountered: {e}")
|
||||
|
||||
# Propagate engine-level safepoint setting
|
||||
engine_safepoint = cfg.get("save_auto_backup", True)
|
||||
env["BB_SAVE_AUTO_BACKUP"] = "1" if engine_safepoint else "0"
|
||||
|
||||
rotate_log()
|
||||
try:
|
||||
self.log_fh = open(LOG_FILE, "w", encoding="utf-8")
|
||||
@@ -1825,7 +2431,7 @@ class BloodborneLauncher(LauncherUI, QMainWindow):
|
||||
self.log_batch([
|
||||
"=" * 60,
|
||||
f"Starting Bloodborne from: {game_dir} [{datetime.now():%Y-%m-%d %H:%M:%S}]",
|
||||
f"Target FPS: {cfg['fps']} | Res: {res_choice} | Window: {win_mode_val}",
|
||||
f"Target FPS: {cfg['fps']} | Res: {res_choice} | Window: {win_mode_val} | Texture Quality: {tex_quality}",
|
||||
f"Active XML Patches ({len(active_patches)}): {', '.join(active_patches) if active_patches else 'None'}",
|
||||
"=" * 60,
|
||||
])
|
||||
@@ -1928,6 +2534,8 @@ class BloodborneLauncher(LauncherUI, QMainWindow):
|
||||
|
||||
self.save_settings()
|
||||
self.update_last_run()
|
||||
if getattr(self, "_saves_built", False):
|
||||
self.refresh_save_manager()
|
||||
|
||||
def closeEvent(self, event):
|
||||
if self.worker and self.worker.isRunning():
|
||||
@@ -1952,14 +2560,22 @@ class BloodborneLauncher(LauncherUI, QMainWindow):
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if sys.platform == "win32":
|
||||
try:
|
||||
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("Bloodborne.PC.Launcher")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
launcher_icon = get_launcher_icon()
|
||||
if not launcher_icon.isNull():
|
||||
app.setWindowIcon(launcher_icon)
|
||||
install_dialog_styling(app)
|
||||
|
||||
if not acquire_single_instance():
|
||||
app = QApplication(sys.argv)
|
||||
install_dialog_styling(app)
|
||||
QMessageBox.information(None, "Bloodborne PC Launcher", "The launcher is already open.")
|
||||
sys.exit(0)
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
install_dialog_styling(app)
|
||||
window = BloodborneLauncher()
|
||||
window.show()
|
||||
sys.exit(app.exec())
|
||||
@@ -30,6 +30,12 @@ if exist "icon.ico" (
|
||||
echo [INFO] icon.ico not found - building with default icon
|
||||
)
|
||||
|
||||
set "DATA_ARGS="
|
||||
if exist "resources" (
|
||||
echo [OK] resources folder found - will embed into executable
|
||||
set "DATA_ARGS=--add-data "resources;resources""
|
||||
)
|
||||
|
||||
if not exist "..\scripts\bb_guard.py" echo [WARN] ..\scripts\bb_guard.py not found - the exe will build without it
|
||||
if not exist "..\scripts\game_check.py" echo [WARN] ..\scripts\game_check.py not found - the exe will build without it
|
||||
echo.
|
||||
@@ -92,6 +98,7 @@ echo.
|
||||
%PY% -m PyInstaller --noconfirm --onefile --windowed ^
|
||||
--name "BloodborneLauncher" ^
|
||||
%ICON_ARGS% ^
|
||||
%DATA_ARGS% ^
|
||||
--distpath ".." ^
|
||||
--paths "." ^
|
||||
--paths "..\scripts" ^
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 353 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 520 KiB |
+141
-135
@@ -1,135 +1,141 @@
|
||||
/* C interface of the GPU library (gpu/): shadPS4's Liverpool/Vulkan video core,
|
||||
* GnmDriver, VideoOut and kernel event queues, adapted to the native loader. */
|
||||
#ifndef BBGPU_H
|
||||
#define BBGPU_H
|
||||
#include <stdint.h>
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
typedef struct {
|
||||
const char *title; /* window title */
|
||||
const char *serial; /* CUSA id, names the pipeline cache */
|
||||
const char *user_dir; /* pipeline cache/logs directory */
|
||||
uint32_t sdk_version; /* from the eboot's procparam */
|
||||
uint32_t psf_attributes; /* param.sfo ATTRIBUTE */
|
||||
int32_t width, height; /* initial window size */
|
||||
} BbGpuConfig;
|
||||
/* Registers kernel event queues (needed with or without graphics). */
|
||||
void bbgpu_register_kernel(void);
|
||||
/* Creates window, Vulkan device, presenter and GPU command processor. */
|
||||
int bbgpu_init(const BbGpuConfig *config);
|
||||
/* Function for an imported NID ("NID#lib#mod"), or 0 when the GPU library does not provide it. */
|
||||
uintptr_t bbgpu_resolve(const char *scoped_nid);
|
||||
/* Called first by the loader's SIGSEGV handler: 1 when a GPU page-tracking fault was handled. */
|
||||
int bbgpu_handle_fault(void *ucontext, void *address);
|
||||
/* BB_WRITE_LOG=1: prints the logged GPU-side writes to guest memory near the fault. */
|
||||
void bbgpu_dump_guest_writes(void *ucontext);
|
||||
/* Keyboard text entry through the game window (IME dialog). begin returns 0 when
|
||||
* no window exists; poll returns 0 typing, 1 confirmed, 2 cancelled (UTF-8 text). */
|
||||
int bbgpu_text_input_begin(const char *initial_utf8, const char *prompt_utf8);
|
||||
int bbgpu_text_input_poll(char *out_utf8, uint64_t size);
|
||||
int bbgpu_text_input_is_active(void);
|
||||
/* 1 while the in-game settings menu is open: the game's pad input is held neutral. */
|
||||
int bbgpu_overlay_captures_input(void);
|
||||
/* Number of symbols registered by the vendored libraries (diagnostics). */
|
||||
unsigned bbgpu_symbol_count(void);
|
||||
/* Flips and submissions query for heartbeat / diagnostics. */
|
||||
uint64_t bbgpu_get_flip_count(void);
|
||||
uint64_t bbgpu_get_submit_count(void);
|
||||
/* Dumps host GPU / presenter thread states and counters to a log file. */
|
||||
void bbgpu_dump_host_threads_hang(void *file_handle);
|
||||
/* Computes average FPS and p95/p99 frame times in milliseconds over recent flips. */
|
||||
void bbgpu_get_frametime_percentiles(double *avg_fps, double *p95_ms, double *p99_ms);
|
||||
/* Dumps GPU breadcrumbs state to log/crash files. */
|
||||
void bbgpu_dump_breadcrumbs(void *file_handle);
|
||||
/* Retrieves GPU device name, driver version, and Vulkan API version. */
|
||||
void bbgpu_get_device_info(char *vendor_buf, size_t vendor_sz,
|
||||
char *model_buf, size_t model_sz,
|
||||
char *driver_buf, size_t driver_sz,
|
||||
char *api_buf, size_t api_sz);
|
||||
/* Consumes accumulated mouse motion delta (pixels) since last call. */
|
||||
void bbgpu_consume_mouse_delta(float *dx, float *dy);
|
||||
/* Returns 1 if mouse look is actively capturing the cursor in relative mode. */
|
||||
int bbgpu_mouse_captured(void);
|
||||
/* Returns 1 if the mouse produced input (motion, button or wheel) within the last 250 ms. */
|
||||
int bbgpu_mouse_active_recent(void);
|
||||
/* Explicitly requests or releases mouse capture. */
|
||||
void bbgpu_set_mouse_capture(int capture);
|
||||
/* Returns 1 if M&K is enabled, and retrieves sensitivity and invert flags. */
|
||||
int bbgpu_get_mk_config(float *sens_x, float *sens_y, int *invert_x, int *invert_y, float *deadzone, float *smoothing);
|
||||
/* Returns 1 if native direct mouse camera rotation is enabled. */
|
||||
int bbgpu_get_mk_native_mouse(void);
|
||||
/* Live engine clock (probe.c hook): mode 1 when live FPS patching is requested, the target
|
||||
* rate (0 = uncapped) and the physics max-dt frequency. Relaxed atomic reads only. */
|
||||
void bbgpu_get_live_clock(int *mode, int *target_fps, int *max_dt_hz);
|
||||
/* The probe reports whether the eboot signature matched and the hook is installed. */
|
||||
void bbgpu_set_live_clock_supported(int supported);
|
||||
/* Per-frame sample for the overlay diagnostic line (dt in seconds, target fps, 0 = uncapped). */
|
||||
void bbgpu_note_live_clock(float dt, int target_fps);
|
||||
/* Hooks the game's menu list update for the mouse pointer; before the image is protected. */
|
||||
int bbgpu_menu_pointer_install(unsigned char *image, uint64_t size);
|
||||
/* Notes the pad's own state and returns the buttons the menu pointer presses this sample. */
|
||||
uint32_t bbgpu_menu_pointer_buttons(uint32_t buttons, uint8_t lx, uint8_t ly);
|
||||
/* The mouse buttons the menu pointer takes while it has the mouse: 1 left (choose), 2 right (back). */
|
||||
int bbgpu_menu_pointer_mouse_buttons(void);
|
||||
|
||||
enum BbCustomKey {
|
||||
BB_KEY_NONE = 0,
|
||||
BB_KEY_MOUSE_LEFT = 601,
|
||||
BB_KEY_MOUSE_RIGHT = 602,
|
||||
BB_KEY_MOUSE_MIDDLE = 603,
|
||||
BB_KEY_MOUSE_X1 = 604,
|
||||
BB_KEY_MOUSE_X2 = 605,
|
||||
BB_KEY_CHORD_SHIFT_LMB = 606,
|
||||
BB_KEY_CHORD_SHIFT_RMB = 607,
|
||||
};
|
||||
|
||||
enum BbAction {
|
||||
BB_ACTION_ATTACK = 0, /* R1 (Light Attack) */
|
||||
BB_ACTION_HEAVY_ATTACK, /* R2 (Heavy / Charged Attack) */
|
||||
BB_ACTION_FIREARM, /* L2 (Gun / Secondary) */
|
||||
BB_ACTION_TRANSFORM, /* L1 (Trick Weapon Transform) */
|
||||
BB_ACTION_LOCK_ON, /* R3 (Lock Target) */
|
||||
BB_ACTION_MOVE_FORWARD, /* Left Stick Up */
|
||||
BB_ACTION_MOVE_BACKWARD, /* Left Stick Down */
|
||||
BB_ACTION_MOVE_LEFT, /* Left Stick Left */
|
||||
BB_ACTION_MOVE_RIGHT, /* Left Stick Right */
|
||||
BB_ACTION_DODGE, /* Circle (Roll / Sprint / Jump while sprinting) */
|
||||
BB_ACTION_INTERACT, /* Cross (Interact / Pick Up / Talk) */
|
||||
BB_ACTION_USE_ITEM, /* Square (Use Quick Item) */
|
||||
BB_ACTION_HEAL, /* Triangle (Heal / Blood Vial) */
|
||||
BB_ACTION_DPAD_UP, /* D-Pad Up (Blood Bullets) */
|
||||
BB_ACTION_DPAD_DOWN, /* D-Pad Down (Switch Item) */
|
||||
BB_ACTION_DPAD_LEFT, /* D-Pad Left (Switch Left Weapon) */
|
||||
BB_ACTION_DPAD_RIGHT, /* D-Pad Right (Switch Right Weapon) */
|
||||
BB_ACTION_GESTURES, /* Touchpad Left (Gestures Menu) */
|
||||
BB_ACTION_PERSONAL_EFFECTS, /* Touchpad Right (Personal Effects) */
|
||||
BB_ACTION_MENU, /* Options (System Menu) */
|
||||
BB_ACTION_WALK, /* Hold: partial left stick deflection */
|
||||
/* End of the gameplay actions. runtime_pad.c only maps these to the virtual pad. */
|
||||
BB_ACTION_GAMEPLAY_COUNT,
|
||||
/* UI hotkeys: configurable from the launcher and the in-game keybind list. */
|
||||
BB_ACTION_OVERLAY_TOGGLE = BB_ACTION_GAMEPLAY_COUNT, /* Settings / debug overlay */
|
||||
BB_ACTION_HUD_TOGGLE, /* Performance HUD */
|
||||
BB_ACTION_CHEAT_TOGGLE, /* In-game cheat menu */
|
||||
BB_ACTION_MOUSE_CAPTURE_TOGGLE, /* Capture / release the mouse */
|
||||
BB_ACTION_COUNT
|
||||
};
|
||||
|
||||
/* Action keybinding queries */
|
||||
int bbgpu_get_action_binding(int action);
|
||||
void bbgpu_get_all_action_bindings(int *out_bindings, int count);
|
||||
void bbgpu_set_action_binding(int action, int code);
|
||||
|
||||
/* Shader recipes & cache rebuilds (Phase 2) */
|
||||
int bbgpu_rebuild_shader_caches(void);
|
||||
int bbgpu_rebuild_shader_cache(const char *dir);
|
||||
int bbgpu_export_shader_recipes(const char *cache_dir, const char *game_dir, const char *out_path);
|
||||
int bbgpu_import_shader_recipes(const char *cache_dir, const char *pack, const char *game_dir,
|
||||
unsigned long long motion_params, unsigned long long motion_positions);
|
||||
int bbgpu_merge_shader_metas(const char *cache_dir, const char *pack, const char *game_dir,
|
||||
const char *meta_dir, const char *out_pack, const char *work_dir, int jobs);
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
/* C interface of the GPU library (gpu/): shadPS4's Liverpool/Vulkan video core,
|
||||
* GnmDriver, VideoOut and kernel event queues, adapted to the native loader. */
|
||||
#ifndef BBGPU_H
|
||||
#define BBGPU_H
|
||||
#include <stdint.h>
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
typedef struct {
|
||||
const char *title; /* window title */
|
||||
const char *serial; /* CUSA id, names the pipeline cache */
|
||||
const char *user_dir; /* pipeline cache/logs directory */
|
||||
uint32_t sdk_version; /* from the eboot's procparam */
|
||||
uint32_t psf_attributes; /* param.sfo ATTRIBUTE */
|
||||
int32_t width, height; /* initial window size */
|
||||
} BbGpuConfig;
|
||||
/* Registers kernel event queues (needed with or without graphics). */
|
||||
void bbgpu_register_kernel(void);
|
||||
/* Creates window, Vulkan device, presenter and GPU command processor. */
|
||||
int bbgpu_init(const BbGpuConfig *config);
|
||||
/* Function for an imported NID ("NID#lib#mod"), or 0 when the GPU library does not provide it. */
|
||||
uintptr_t bbgpu_resolve(const char *scoped_nid);
|
||||
/* Called first by the loader's SIGSEGV handler: 1 when a GPU page-tracking fault was handled. */
|
||||
int bbgpu_handle_fault(void *ucontext, void *address);
|
||||
/* Set while a fault from the PS4 red-zone vertex generators is being handled: their pages must
|
||||
* stay writable, because the Windows exception frame destroys the red-zone locals. */
|
||||
void bbgpu_set_redzone_fault(int active);
|
||||
/* BB_WRITE_LOG=1: prints the logged GPU-side writes to guest memory near the fault. */
|
||||
void bbgpu_dump_guest_writes(void *ucontext);
|
||||
/* Keyboard text entry through the game window (IME dialog). begin returns 0 when
|
||||
* no window exists; poll returns 0 typing, 1 confirmed, 2 cancelled (UTF-8 text). */
|
||||
int bbgpu_text_input_begin(const char *initial_utf8, const char *prompt_utf8);
|
||||
int bbgpu_text_input_poll(char *out_utf8, uint64_t size);
|
||||
int bbgpu_text_input_is_active(void);
|
||||
/* 1 while the in-game settings menu is open: the game's pad input is held neutral. */
|
||||
int bbgpu_overlay_captures_input(void);
|
||||
/* Number of symbols registered by the vendored libraries (diagnostics). */
|
||||
unsigned bbgpu_symbol_count(void);
|
||||
/* Flips and submissions query for heartbeat / diagnostics. */
|
||||
uint64_t bbgpu_get_flip_count(void);
|
||||
uint64_t bbgpu_get_submit_count(void);
|
||||
/* Dumps host GPU / presenter thread states and counters to a log file. */
|
||||
void bbgpu_dump_host_threads_hang(void *file_handle);
|
||||
/* Computes average FPS and p95/p99 frame times in milliseconds over recent flips. */
|
||||
void bbgpu_get_frametime_percentiles(double *avg_fps, double *p95_ms, double *p99_ms);
|
||||
/* Dumps GPU breadcrumbs state to log/crash files. */
|
||||
void bbgpu_dump_breadcrumbs(void *file_handle);
|
||||
/* Retrieves GPU device name, driver version, and Vulkan API version. */
|
||||
void bbgpu_get_device_info(char *vendor_buf, size_t vendor_sz,
|
||||
char *model_buf, size_t model_sz,
|
||||
char *driver_buf, size_t driver_sz,
|
||||
char *api_buf, size_t api_sz);
|
||||
/* Consumes accumulated mouse motion delta (pixels) since last call. */
|
||||
void bbgpu_consume_mouse_delta(float *dx, float *dy);
|
||||
/* Returns 1 if mouse look is actively capturing the cursor in relative mode. */
|
||||
int bbgpu_mouse_captured(void);
|
||||
/* Returns 1 if the mouse produced input (motion, button or wheel) within the last 250 ms. */
|
||||
int bbgpu_mouse_active_recent(void);
|
||||
/* Explicitly requests or releases mouse capture. */
|
||||
void bbgpu_set_mouse_capture(int capture);
|
||||
/* Returns 1 if M&K is enabled, and retrieves sensitivity and invert flags. */
|
||||
int bbgpu_get_mk_config(float *sens_x, float *sens_y, int *invert_x, int *invert_y, float *deadzone, float *smoothing);
|
||||
/* Returns 1 if native direct mouse camera rotation is enabled. */
|
||||
int bbgpu_get_mk_native_mouse(void);
|
||||
/* Live engine clock (probe.c hook): mode 1 when live FPS patching is requested, the target
|
||||
* rate (0 = uncapped) and the physics max-dt frequency. Relaxed atomic reads only. */
|
||||
void bbgpu_get_live_clock(int *mode, int *target_fps, int *max_dt_hz);
|
||||
/* The probe reports whether the eboot signature matched and the hook is installed. */
|
||||
void bbgpu_set_live_clock_supported(int supported);
|
||||
/* Per-frame sample for the overlay diagnostic line (dt in seconds, target fps, 0 = uncapped). */
|
||||
void bbgpu_note_live_clock(float dt, int target_fps);
|
||||
/* Hooks the game's menu list update for the mouse pointer; before the image is protected. */
|
||||
int bbgpu_menu_pointer_install(unsigned char *image, uint64_t size);
|
||||
/* Notes the pad's own state and returns the buttons the menu pointer presses this sample. */
|
||||
uint32_t bbgpu_menu_pointer_buttons(uint32_t buttons, uint8_t lx, uint8_t ly);
|
||||
/* The mouse buttons the menu pointer takes while it has the mouse: 1 left (choose), 2 right (back). */
|
||||
int bbgpu_menu_pointer_mouse_buttons(void);
|
||||
|
||||
enum BbCustomKey {
|
||||
BB_KEY_NONE = 0,
|
||||
BB_KEY_MOUSE_LEFT = 601,
|
||||
BB_KEY_MOUSE_RIGHT = 602,
|
||||
BB_KEY_MOUSE_MIDDLE = 603,
|
||||
BB_KEY_MOUSE_X1 = 604,
|
||||
BB_KEY_MOUSE_X2 = 605,
|
||||
BB_KEY_CHORD_SHIFT_LMB = 606,
|
||||
BB_KEY_CHORD_SHIFT_RMB = 607,
|
||||
};
|
||||
|
||||
enum BbAction {
|
||||
BB_ACTION_ATTACK = 0, /* R1 (Light Attack) */
|
||||
BB_ACTION_HEAVY_ATTACK, /* R2 (Heavy / Charged Attack) */
|
||||
BB_ACTION_FIREARM, /* L2 (Gun / Secondary) */
|
||||
BB_ACTION_TRANSFORM, /* L1 (Trick Weapon Transform) */
|
||||
BB_ACTION_LOCK_ON, /* R3 (Lock Target) */
|
||||
BB_ACTION_MOVE_FORWARD, /* Left Stick Up */
|
||||
BB_ACTION_MOVE_BACKWARD, /* Left Stick Down */
|
||||
BB_ACTION_MOVE_LEFT, /* Left Stick Left */
|
||||
BB_ACTION_MOVE_RIGHT, /* Left Stick Right */
|
||||
BB_ACTION_DODGE, /* Circle (Roll / Sprint / Jump while sprinting) */
|
||||
BB_ACTION_INTERACT, /* Cross (Interact / Pick Up / Talk) */
|
||||
BB_ACTION_USE_ITEM, /* Square (Use Quick Item) */
|
||||
BB_ACTION_HEAL, /* Triangle (Heal / Blood Vial) */
|
||||
BB_ACTION_DPAD_UP, /* D-Pad Up (Blood Bullets) */
|
||||
BB_ACTION_DPAD_DOWN, /* D-Pad Down (Switch Item) */
|
||||
BB_ACTION_DPAD_LEFT, /* D-Pad Left (Switch Left Weapon) */
|
||||
BB_ACTION_DPAD_RIGHT, /* D-Pad Right (Switch Right Weapon) */
|
||||
BB_ACTION_GESTURES, /* Touchpad Left (Gestures Menu) */
|
||||
BB_ACTION_PERSONAL_EFFECTS, /* Touchpad Right (Personal Effects) */
|
||||
BB_ACTION_MENU, /* Options (System Menu) */
|
||||
BB_ACTION_WALK, /* Hold: partial left stick deflection */
|
||||
/* End of the gameplay actions. runtime_pad.c only maps these to the virtual pad. */
|
||||
BB_ACTION_GAMEPLAY_COUNT,
|
||||
/* UI hotkeys: configurable from the launcher and the in-game keybind list. */
|
||||
BB_ACTION_OVERLAY_TOGGLE = BB_ACTION_GAMEPLAY_COUNT, /* Settings / debug overlay */
|
||||
BB_ACTION_HUD_TOGGLE, /* Performance HUD */
|
||||
BB_ACTION_CHEAT_TOGGLE, /* In-game cheat menu */
|
||||
BB_ACTION_MOUSE_CAPTURE_TOGGLE, /* Capture / release the mouse */
|
||||
BB_ACTION_COUNT
|
||||
};
|
||||
|
||||
/* Action keybinding queries */
|
||||
int bbgpu_get_action_binding(int action);
|
||||
void bbgpu_get_all_action_bindings(int *out_bindings, int count);
|
||||
void bbgpu_set_action_binding(int action, int code);
|
||||
|
||||
/* Save data safeguards */
|
||||
int bbgpu_get_save_auto_backup(void);
|
||||
|
||||
/* Shader recipes & cache rebuilds (Phase 2) */
|
||||
int bbgpu_rebuild_shader_caches(void);
|
||||
int bbgpu_rebuild_shader_cache(const char *dir);
|
||||
int bbgpu_export_shader_recipes(const char *cache_dir, const char *game_dir, const char *out_path);
|
||||
int bbgpu_import_shader_recipes(const char *cache_dir, const char *pack, const char *game_dir,
|
||||
unsigned long long motion_params, unsigned long long motion_positions);
|
||||
int bbgpu_merge_shader_metas(const char *cache_dir, const char *pack, const char *game_dir,
|
||||
const char *meta_dir, const char *out_pack, const char *work_dir, int jobs);
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
@@ -125,9 +125,11 @@ struct Api {
|
||||
} // namespace
|
||||
|
||||
void Apply() {
|
||||
if (bb_env_off("BB_NV_POWER")) {
|
||||
static bool applied = false;
|
||||
if (applied || bb_env_off("BB_NV_POWER")) {
|
||||
return;
|
||||
}
|
||||
applied = true;
|
||||
Api api{};
|
||||
if (!api.Load() || api.initialize() != Ok) {
|
||||
return; // no NVIDIA driver
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2024-2026 shadPS4 Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <pthread.h>
|
||||
#ifdef _WIN32
|
||||
@@ -81,6 +83,35 @@ static std::span<const u32> NextPacket(std::span<const u32> span, size_t offset)
|
||||
return span.subspan(offset);
|
||||
}
|
||||
|
||||
/* bbport: a PM4 stream that cannot be parsed (wrong packet type, unknown opcode) is malformed or
|
||||
* desynced: the following dwords are not packets, so parsing on executes garbage - that is the
|
||||
* "Wrong PM4 type 1"/"5026 vs 3273" desync that used to end in UNREACHABLE_MSG (exit code 23) or
|
||||
* a fault. Dump the packet head once per occurrence (rate limited) and let the caller abandon
|
||||
* the submission or skip the packet; one wrong frame instead of a dead process. */
|
||||
static void ReportBadPm4(const char* what, u32 value, std::span<const u32> words, bool abandon) {
|
||||
static std::atomic<u32> reported{0};
|
||||
if (reported.fetch_add(1, std::memory_order_relaxed) >= 32) {
|
||||
return;
|
||||
}
|
||||
std::array<u32, 8> dwords{};
|
||||
const size_t count = words.size() < dwords.size() ? words.size() : dwords.size();
|
||||
for (size_t i = 0; i < count; ++i) {
|
||||
dwords[i] = words[i];
|
||||
}
|
||||
LOG_CRITICAL(Render,
|
||||
"PM4: {} ({:#x}){}; dwords: {:08x} {:08x} {:08x} {:08x} {:08x} {:08x} {:08x} "
|
||||
"{:08x}",
|
||||
what, value, abandon ? " - abandoning the rest of the submission" : "", dwords[0],
|
||||
dwords[1], dwords[2], dwords[3], dwords[4], dwords[5], dwords[6], dwords[7]);
|
||||
}
|
||||
|
||||
/// bbport: true while a malformed-packet message may still be logged (the packet loop can repeat
|
||||
/// the same issue every submission, and a log line per frame costs more than the skipped work).
|
||||
static bool ReportPm4Issue() {
|
||||
static std::atomic<u32> reported{0};
|
||||
return reported.fetch_add(1, std::memory_order_relaxed) < 16;
|
||||
}
|
||||
|
||||
Liverpool::Liverpool() : guest_markers_enabled{EmulatorSettings.IsVkGuestMarkersEnabled()} {
|
||||
num_counter_pairs = Libraries::Kernel::sceKernelIsNeoMode() ? 16 : 8;
|
||||
process_thread = std::jthread{std::bind_front(&Liverpool::Process, this)};
|
||||
@@ -371,8 +402,14 @@ void RunDmaData(Vulkan::Rasterizer& rasterizer, const u8* data) {
|
||||
dma_data->SrcAddress<VAddr>(), dma_data->NumBytes(),
|
||||
false, false);
|
||||
} else {
|
||||
UNREACHABLE_MSG("WriteData src_sel = {}, dst_sel = {}", u32(dma_data->src_sel),
|
||||
u32(dma_data->dst_sel));
|
||||
/* bbport: an unimplemented DMA source/destination pair. Skipping this one copy cannot
|
||||
* corrupt unrelated state, while UNREACHABLE_MSG ended the process (exit code 23). */
|
||||
static std::atomic<u32> reported{0};
|
||||
if (reported.fetch_add(1, std::memory_order_relaxed) < 8) {
|
||||
LOG_ERROR(Render,
|
||||
"WriteData: unsupported transfer src_sel = {}, dst_sel = {}; copy skipped",
|
||||
u32(dma_data->src_sel), u32(dma_data->dst_sel));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -411,7 +448,15 @@ void RunEventWriteEos(Vulkan::Rasterizer& rasterizer, const u8* data) {
|
||||
}
|
||||
});
|
||||
if (event_eos.command == PM4CmdEventWriteEos::Command::GdsStore) {
|
||||
ASSERT(event_eos.size == 1);
|
||||
/* bbport: the GDS store below assumes one dword. Skipping it is safe; the old ASSERT
|
||||
* ended the process. */
|
||||
if (event_eos.size != 1) {
|
||||
if (ReportPm4Issue()) {
|
||||
LOG_ERROR(Render, "EventWriteEos GdsStore: unexpected size {}; store skipped",
|
||||
u32(event_eos.size));
|
||||
}
|
||||
return;
|
||||
}
|
||||
rasterizer.Finish();
|
||||
const u32 value = rasterizer.ReadDataFromGds(event_eos.gds_index);
|
||||
*event_eos.Address() = value;
|
||||
@@ -475,8 +520,10 @@ Liverpool::Task Liverpool::ProcessCeUpdate(std::span<const u32> ccb) {
|
||||
continue;
|
||||
}
|
||||
if (type != 3) {
|
||||
// No other types of packets were spotted so far
|
||||
UNREACHABLE_MSG("Invalid PM4 type {}", type);
|
||||
// No other types of packets were spotted so far: the stream is desynced.
|
||||
ReportBadPm4("Invalid PM4 type (CE)", type, ccb, true);
|
||||
ccb = {};
|
||||
break;
|
||||
}
|
||||
|
||||
const PM4ItOpcode opcode = header->type3.opcode;
|
||||
@@ -543,9 +590,8 @@ Liverpool::Task Liverpool::ProcessCeUpdate(std::span<const u32> ccb) {
|
||||
break;
|
||||
}
|
||||
default:
|
||||
const u32 count = header->type3.NumWords();
|
||||
UNREACHABLE_MSG("Unknown PM4 type 3 opcode {:#x} with count {}",
|
||||
static_cast<u32>(opcode), count);
|
||||
// The header is well formed (type 3 with a known length): skip this packet only.
|
||||
ReportBadPm4("Unknown PM4 type 3 opcode (CE)", static_cast<u32>(opcode), ccb, false);
|
||||
}
|
||||
ccb = NextPacket(ccb, packet_words);
|
||||
}
|
||||
@@ -812,7 +858,9 @@ Liverpool::Task Liverpool::ProcessGraphics(std::span<const u32> dcb, u64 seq) {
|
||||
|
||||
switch (type) {
|
||||
default:
|
||||
UNREACHABLE_MSG("Wrong PM4 type {}", type);
|
||||
// There is no length to trust for an unknown type: drop the rest of this submission.
|
||||
ReportBadPm4("Wrong PM4 type (graphics)", type, dcb, true);
|
||||
dcb = {};
|
||||
break;
|
||||
case 0: {
|
||||
// A zero dword is often padding (alignment or trailing) in command chunks.
|
||||
@@ -941,9 +989,10 @@ Liverpool::Task Liverpool::ProcessGraphics(std::span<const u32> dcb, u64 seq) {
|
||||
ASSERT(col_buf_id < NUM_COLOR_BUFFERS);
|
||||
|
||||
const auto nop_offset = header->type3.count;
|
||||
if (nop_offset == 0x0e || nop_offset == 0x0d || nop_offset == 0x0b) {
|
||||
ASSERT_MSG(payload[nop_offset] == 0xc0001000,
|
||||
"NOP hint is missing in CB setup sequence");
|
||||
/* bbport: a missing trailing NOP hint only means the extent is unknown;
|
||||
* clear it instead of aborting the process (exit code 23). */
|
||||
if ((nop_offset == 0x0e || nop_offset == 0x0d || nop_offset == 0x0b) &&
|
||||
payload[nop_offset] == 0xc0001000) {
|
||||
last_cb_extent[col_buf_id].raw = payload[nop_offset + 1];
|
||||
} else {
|
||||
last_cb_extent[col_buf_id].raw = 0;
|
||||
@@ -964,17 +1013,13 @@ Liverpool::Task Liverpool::ProcessGraphics(std::span<const u32> dcb, u64 seq) {
|
||||
ASSERT(col_buf_id < NUM_COLOR_BUFFERS);
|
||||
|
||||
const auto nop_offset = header->type3.count;
|
||||
if (nop_offset == 0x04) {
|
||||
ASSERT_MSG(payload[nop_offset] == 0xc0001000,
|
||||
"NOP hint is missing in CB setup sequence");
|
||||
if (nop_offset == 0x04 && payload[nop_offset] == 0xc0001000) {
|
||||
last_cb_extent[col_buf_id].raw = payload[nop_offset + 1];
|
||||
}
|
||||
break;
|
||||
}
|
||||
case ContextRegs::DbZInfo: {
|
||||
if (header->type3.count == 8) {
|
||||
ASSERT_MSG(payload[20] == 0xc0001000,
|
||||
"NOP hint is missing in DB setup sequence");
|
||||
if (header->type3.count == 8 && payload[20] == 0xc0001000) {
|
||||
last_db_extent.raw = payload[21];
|
||||
} else {
|
||||
last_db_extent.raw = 0;
|
||||
@@ -992,7 +1037,17 @@ Liverpool::Task Liverpool::ProcessGraphics(std::span<const u32> dcb, u64 seq) {
|
||||
|
||||
if (set_data->reg_offset >= 0x200 &&
|
||||
set_data->reg_offset <= (0x200 + sizeof(ComputeProgram) / 4)) {
|
||||
ASSERT(set_size <= sizeof(ComputeProgram));
|
||||
/* bbport: never memcpy past cs_state. A packet claiming more than the
|
||||
* program block holds is malformed; skip it instead of overflowing. */
|
||||
if (set_size > sizeof(ComputeProgram)) {
|
||||
if (ReportPm4Issue()) {
|
||||
LOG_WARNING(Render,
|
||||
"SetShReg (graphics): packet size {} exceeds the compute "
|
||||
"program block; skipped",
|
||||
set_size);
|
||||
}
|
||||
break;
|
||||
}
|
||||
auto* addr = reinterpret_cast<u32*>(&mapped_queues[GfxQueueId].cs_state) +
|
||||
(set_data->reg_offset - 0x200);
|
||||
std::memcpy(addr, header + 2, set_size);
|
||||
@@ -1199,7 +1254,14 @@ Liverpool::Task Liverpool::ProcessGraphics(std::span<const u32> dcb, u64 seq) {
|
||||
}
|
||||
case PM4ItOpcode::SetBase: {
|
||||
const auto* set_base = reinterpret_cast<const PM4CmdSetBase*>(header);
|
||||
ASSERT(set_base->base_index == PM4CmdSetBase::BaseIndex::DrawIndexIndirPatchTable);
|
||||
if (set_base->base_index != PM4CmdSetBase::BaseIndex::DrawIndexIndirPatchTable) {
|
||||
if (ReportPm4Issue()) {
|
||||
LOG_WARNING(Render,
|
||||
"SetBase: unexpected base index {}; packet skipped",
|
||||
magic_enum::enum_name(set_base->base_index.Value()));
|
||||
}
|
||||
break;
|
||||
}
|
||||
indirect_args_addr = set_base->Address<u64>();
|
||||
break;
|
||||
}
|
||||
@@ -1264,8 +1326,28 @@ Liverpool::Task Liverpool::ProcessGraphics(std::span<const u32> dcb, u64 seq) {
|
||||
}
|
||||
case PM4ItOpcode::WriteData: {
|
||||
const auto* write_data = reinterpret_cast<const PM4CmdWriteData*>(header);
|
||||
ASSERT(write_data->dst_sel.Value() == 2 || write_data->dst_sel.Value() == 5);
|
||||
ASSERT(!write_data->wr_one_addr.Value());
|
||||
/* bbport: only the two memory destination encodings and the "data in packet"
|
||||
* form below are understood. Anything else used to abort (exit code 23);
|
||||
* skipping one write keeps the process alive. */
|
||||
if ((write_data->dst_sel.Value() != 2 && write_data->dst_sel.Value() != 5) ||
|
||||
write_data->wr_one_addr.Value()) {
|
||||
if (ReportPm4Issue()) {
|
||||
LOG_WARNING(Render,
|
||||
"WriteData: unsupported dst_sel = {}, wr_one_addr = {}; packet "
|
||||
"skipped",
|
||||
write_data->dst_sel.Value(), write_data->wr_one_addr.Value());
|
||||
}
|
||||
break;
|
||||
}
|
||||
/* bbport: the data size below is (count - 2) dwords; a shorter packet would
|
||||
* underflow to a huge memcpy (access violation) - skip it. */
|
||||
if (count < 2) {
|
||||
if (ReportPm4Issue()) {
|
||||
LOG_WARNING(Render, "WriteData: packet too short (count {}); skipped",
|
||||
count);
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (rasterizer) {
|
||||
// In order with the draws (on the draw recording thread when in use).
|
||||
BbWriteLog::NoteIntent(write_data->Address<u64>(), write_data->data,
|
||||
@@ -1451,8 +1533,9 @@ Liverpool::Task Liverpool::ProcessGraphics(std::span<const u32> dcb, u64 seq) {
|
||||
break;
|
||||
}
|
||||
default:
|
||||
UNREACHABLE_MSG("Unknown PM4 type 3 opcode {:#x} with count {}",
|
||||
static_cast<u32>(opcode), count);
|
||||
// Type-3 header with a known length: skip this packet and keep the submission.
|
||||
ReportBadPm4("Unknown PM4 type 3 opcode (graphics)", static_cast<u32>(opcode), dcb,
|
||||
false);
|
||||
}
|
||||
// The size read before running the packet: a flip or an EOP interrupt lets the guest
|
||||
// reuse this buffer while we are still on it, so the header may be gone by now.
|
||||
@@ -1563,8 +1646,13 @@ Liverpool::Task Liverpool::ProcessCompute(std::span<const u32> acb, u32 vqid) {
|
||||
}
|
||||
break;
|
||||
}
|
||||
// No other types of packets were spotted so far
|
||||
UNREACHABLE_MSG("Invalid PM4 type {}", header->type.Value());
|
||||
// No other types of packets were spotted so far: the stream is desynced.
|
||||
ReportBadPm4("Invalid PM4 type (compute)", header->type.Value(), acb, true);
|
||||
if constexpr (!is_indirect) {
|
||||
*queue.read_addr += acb.size();
|
||||
*queue.read_addr %= queue.ring_size_dw;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const PM4ItOpcode opcode = header->type3.opcode;
|
||||
@@ -1603,7 +1691,15 @@ Liverpool::Task Liverpool::ProcessCompute(std::span<const u32> acb, u32 vqid) {
|
||||
if (dma_data->dst_addr_lo == 0x3022C || !rasterizer) {
|
||||
break;
|
||||
}
|
||||
ASSERT(dma_data->command.das == 0);
|
||||
/* bbport: `das` selects an unsupported data-size/layout form. Skipping this one
|
||||
* transfer is safe; the old ASSERT ended the process. */
|
||||
if (dma_data->command.das != 0) {
|
||||
if (ReportPm4Issue()) {
|
||||
LOG_WARNING(Render, "DmaData: unsupported data-size field {}; packet skipped",
|
||||
dma_data->command.das);
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (dma_data->src_sel == DmaDataSrc::Data && dma_data->dst_sel == DmaDataDst::Gds) {
|
||||
rasterizer->FillBuffer(dma_data->dst_addr_lo, dma_data->NumBytes(), dma_data->data,
|
||||
true);
|
||||
@@ -1639,8 +1735,13 @@ Liverpool::Task Liverpool::ProcessCompute(std::span<const u32> acb, u32 vqid) {
|
||||
rasterizer->CopyBuffer(dst_addr, src_addr, num_bytes, false, false);
|
||||
}
|
||||
} else {
|
||||
UNREACHABLE_MSG("WriteData src_sel = {}, dst_sel = {}", u32(dma_data->src_sel),
|
||||
u32(dma_data->dst_sel));
|
||||
// bbport: unimplemented transfer pair; skip it instead of exiting.
|
||||
if (ReportPm4Issue()) {
|
||||
LOG_WARNING(Render,
|
||||
"DmaData: unsupported transfer src_sel = {}, dst_sel = {}; "
|
||||
"packet skipped",
|
||||
u32(dma_data->src_sel), u32(dma_data->dst_sel));
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -1663,7 +1764,16 @@ Liverpool::Task Liverpool::ProcessCompute(std::span<const u32> acb, u32 vqid) {
|
||||
|
||||
if (set_data->reg_offset >= 0x200 &&
|
||||
set_data->reg_offset <= (0x200 + sizeof(ComputeProgram) / 4)) {
|
||||
ASSERT(set_size <= sizeof(ComputeProgram));
|
||||
/* bbport: never memcpy past cs_state; skip a malformed packet instead. */
|
||||
if (set_size > sizeof(ComputeProgram)) {
|
||||
if (ReportPm4Issue()) {
|
||||
LOG_WARNING(Render,
|
||||
"SetShReg (compute): packet size {} exceeds the compute "
|
||||
"program block; skipped",
|
||||
set_size);
|
||||
}
|
||||
break;
|
||||
}
|
||||
auto* addr = reinterpret_cast<u32*>(&mapped_queues[vqid + 1].cs_state) +
|
||||
(set_data->reg_offset - 0x200);
|
||||
std::memcpy(addr, header + 2, set_size);
|
||||
@@ -1737,13 +1847,29 @@ Liverpool::Task Liverpool::ProcessCompute(std::span<const u32> acb, u32 vqid) {
|
||||
rasterizer->WaitDeferredSignals();
|
||||
}
|
||||
const auto* write_data = reinterpret_cast<const PM4CmdWriteData*>(header);
|
||||
ASSERT(write_data->dst_sel.Value() == 2 || write_data->dst_sel.Value() == 5);
|
||||
const u32 data_size = (header->type3.count.Value() - 2) * 4;
|
||||
if (!write_data->wr_one_addr.Value()) {
|
||||
std::memcpy(write_data->Address<void*>(), write_data->data, data_size);
|
||||
} else {
|
||||
UNREACHABLE();
|
||||
/* bbport: only the two memory destination encodings and the "data in packet" form
|
||||
* are understood. Anything else used to abort; skip this write instead. */
|
||||
if ((write_data->dst_sel.Value() != 2 && write_data->dst_sel.Value() != 5) ||
|
||||
write_data->wr_one_addr.Value()) {
|
||||
if (ReportPm4Issue()) {
|
||||
LOG_WARNING(Render,
|
||||
"WriteData (compute): unsupported dst_sel = {}, wr_one_addr = {}; "
|
||||
"packet skipped",
|
||||
write_data->dst_sel.Value(), write_data->wr_one_addr.Value());
|
||||
}
|
||||
break;
|
||||
}
|
||||
/* bbport: the data size below is (count - 2) dwords; a shorter packet would
|
||||
* underflow to a huge memcpy (access violation) - skip it. */
|
||||
if (header->type3.count.Value() < 2) {
|
||||
if (ReportPm4Issue()) {
|
||||
LOG_WARNING(Render, "WriteData (compute): packet too short (count {}); skipped",
|
||||
header->type3.count.Value());
|
||||
}
|
||||
break;
|
||||
}
|
||||
const u32 data_size = (header->type3.count.Value() - 2) * 4;
|
||||
std::memcpy(write_data->Address<void*>(), write_data->data, data_size);
|
||||
break;
|
||||
}
|
||||
case PM4ItOpcode::MemSemaphore: {
|
||||
@@ -1760,7 +1886,15 @@ Liverpool::Task Liverpool::ProcessCompute(std::span<const u32> acb, u32 vqid) {
|
||||
}
|
||||
case PM4ItOpcode::WaitRegMem: {
|
||||
const auto* wait_reg_mem = reinterpret_cast<const PM4CmdWaitRegMem*>(header);
|
||||
ASSERT(wait_reg_mem->engine.Value() == PM4CmdWaitRegMem::Engine::Me);
|
||||
/* bbport: Test() only reads the register file for engine Me; for any other engine
|
||||
* the loop below would test the wrong state and could spin forever. Skip it. */
|
||||
if (wait_reg_mem->engine.Value() != PM4CmdWaitRegMem::Engine::Me) {
|
||||
if (ReportPm4Issue()) {
|
||||
LOG_WARNING(Render, "WaitRegMem: unsupported engine {}; packet skipped",
|
||||
magic_enum::enum_name(wait_reg_mem->engine.Value()));
|
||||
}
|
||||
break;
|
||||
}
|
||||
while (!wait_reg_mem->Test(regs.reg_array)) {
|
||||
YIELD_ASC(vqid);
|
||||
}
|
||||
@@ -1796,8 +1930,9 @@ Liverpool::Task Liverpool::ProcessCompute(std::span<const u32> acb, u32 vqid) {
|
||||
break;
|
||||
}
|
||||
default:
|
||||
UNREACHABLE_MSG("Unknown PM4 type 3 opcode {:#x} with count {}",
|
||||
static_cast<u32>(opcode), header->type3.NumWords());
|
||||
// Type-3 header with a known length: skip this packet and keep the submission.
|
||||
ReportBadPm4("Unknown PM4 type 3 opcode (compute)", static_cast<u32>(opcode), acb,
|
||||
false);
|
||||
}
|
||||
|
||||
acb = NextPacket(acb, next_dw_off);
|
||||
@@ -1818,10 +1953,26 @@ void Liverpool::HandleGetLodStats(const PM4CmdGetLodStats* get_lod) {
|
||||
|
||||
Liverpool::CmdBuffer Liverpool::CopyCmdBuffers(std::span<const u32> dcb, std::span<const u32> ccb) {
|
||||
auto& queue = mapped_queues[GfxQueueId];
|
||||
ASSERT_MSG(queue.dcb_buffer.capacity() >= queue.dcb_buffer_offset + dcb.size(),
|
||||
"dcb copy buffer out of reserved space");
|
||||
ASSERT_MSG(queue.ccb_buffer.capacity() >= queue.ccb_buffer_offset + ccb.size(),
|
||||
"ccb copy buffer out of reserved space");
|
||||
/* bbport: the reserved capacity is sized for the largest submission the guest was seen to
|
||||
* send, but it can exceed that. The assert ended the process; growing may reallocate these
|
||||
* buffers, and the incoming spans can point into them, so copy the input first in that case. */
|
||||
std::vector<u32> dcb_keep, ccb_keep;
|
||||
if (queue.dcb_buffer.capacity() < queue.dcb_buffer_offset + dcb.size()) {
|
||||
if (ReportPm4Issue()) {
|
||||
LOG_WARNING(Render, "dcb copy buffer too small ({} > {}), growing",
|
||||
queue.dcb_buffer_offset + dcb.size(), queue.dcb_buffer.capacity());
|
||||
}
|
||||
dcb_keep.assign(dcb.begin(), dcb.end());
|
||||
dcb = dcb_keep;
|
||||
}
|
||||
if (queue.ccb_buffer.capacity() < queue.ccb_buffer_offset + ccb.size()) {
|
||||
if (ReportPm4Issue()) {
|
||||
LOG_WARNING(Render, "ccb copy buffer too small ({} > {}), growing",
|
||||
queue.ccb_buffer_offset + ccb.size(), queue.ccb_buffer.capacity());
|
||||
}
|
||||
ccb_keep.assign(ccb.begin(), ccb.end());
|
||||
ccb = ccb_keep;
|
||||
}
|
||||
|
||||
queue.dcb_buffer.resize(
|
||||
std::max(queue.dcb_buffer.size(), queue.dcb_buffer_offset + dcb.size()));
|
||||
@@ -1882,7 +2033,15 @@ void Liverpool::SubmitGfx(std::span<const u32> dcb, std::span<const u32> ccb) {
|
||||
}
|
||||
|
||||
void Liverpool::SubmitAsc(u32 gnm_vqid, std::span<const u32> acb) {
|
||||
ASSERT_MSG(gnm_vqid > 0 && gnm_vqid < NumTotalQueues, "Invalid virtual ASC queue index");
|
||||
/* bbport: the queue index comes from the guest; an out-of-range one used to abort and would
|
||||
* otherwise index mapped_queues out of bounds. Drop that submission instead. */
|
||||
if (gnm_vqid == 0 || gnm_vqid >= NumTotalQueues) {
|
||||
if (ReportPm4Issue()) {
|
||||
LOG_ERROR(Render, "SubmitAsc: invalid virtual queue {} (max {}), submission dropped",
|
||||
gnm_vqid, u32{NumTotalQueues});
|
||||
}
|
||||
return;
|
||||
}
|
||||
auto& queue = mapped_queues[gnm_vqid];
|
||||
|
||||
const auto vqid = gnm_vqid - 1;
|
||||
|
||||
@@ -148,6 +148,12 @@ void BufferCache::ExtendWriteFault(VAddr device_addr) {
|
||||
memory_tracker->ExtendWriteFault(Common::AlignDown(device_addr, window), window);
|
||||
}
|
||||
|
||||
void BufferCache::MakeWriteFaultSticky(VAddr device_addr) {
|
||||
// One byte: pin only the page the faulting access touched, even when the address is not
|
||||
// page aligned (the caller passes the fault address itself).
|
||||
memory_tracker->MakeSticky(device_addr, 1);
|
||||
}
|
||||
|
||||
void BufferCache::UnmarkRegionAsGpuModified(VAddr device_addr, u64 size, bool is_write) {
|
||||
memory_tracker->UnmarkRegionAsGpuModified(device_addr, size);
|
||||
gpu_modified_ranges.Subtract(device_addr, size);
|
||||
|
||||
@@ -89,6 +89,9 @@ public:
|
||||
/// bbport: a guest write fault at `device_addr` was handled; unprotect its neighbourhood.
|
||||
void ExtendWriteFault(VAddr device_addr);
|
||||
|
||||
/// bbport: pin the page a red-zone vertex generator wrote (see RegionManager::MakeSticky).
|
||||
void MakeWriteFaultSticky(VAddr device_addr);
|
||||
|
||||
/// Flushes any GPU modified buffer in the logical page range back to CPU memory.
|
||||
void ReadMemory(VAddr device_addr, u64 size, bool is_write = false, bool async = false, bool assume_locks = false);
|
||||
|
||||
|
||||
@@ -62,6 +62,15 @@ public:
|
||||
});
|
||||
}
|
||||
|
||||
/// bbport: see RegionManager::MakeSticky (red-zone vertex generators).
|
||||
void MakeSticky(VAddr fault_addr, u64 size) {
|
||||
IteratePages<false>(fault_addr, size,
|
||||
[](RegionManager* manager, u64 offset, size_t size) {
|
||||
std::scoped_lock lk{manager->lock};
|
||||
manager->MakeSticky(manager->GetCpuAddr() + offset, size);
|
||||
});
|
||||
}
|
||||
|
||||
/// Unmark region as modified from the host GPU
|
||||
void UnmarkRegionAsGpuModified(VAddr dirty_cpu_addr, u64 query_size) noexcept {
|
||||
IteratePages<false>(dirty_cpu_addr, query_size,
|
||||
|
||||
@@ -4,8 +4,10 @@
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cstdlib>
|
||||
#include <algorithm>
|
||||
|
||||
#include "bbport_toggles.h"
|
||||
#include "common/div_ceil.h"
|
||||
@@ -30,6 +32,20 @@ using LockType = Common::AdaptiveMutex;
|
||||
using LockType = Common::SpinLock;
|
||||
#endif
|
||||
|
||||
/// bbport: pages pinned by the red-zone generator workaround. A pinned page is never re-protected
|
||||
/// and counts as CPU modified, so it is uploaded on every binding; the cap (BB_STICKY_FAULT_PAGES,
|
||||
/// default 2048 pages = 8 MiB) keeps a runaway set from turning into the 14k-page hot set that
|
||||
/// cost 33 FPS (see RegionManager::HotPagesEnabled).
|
||||
inline std::atomic<u32> g_sticky_fault_pages{0};
|
||||
inline u32 StickyFaultPageBudget() {
|
||||
static const u32 budget = [] {
|
||||
const char* env = std::getenv("BB_STICKY_FAULT_PAGES");
|
||||
const long value = env ? std::strtol(env, nullptr, 10) : 2048;
|
||||
return static_cast<u32>(value > 0 ? value : 0);
|
||||
}();
|
||||
return budget;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows tracking CPU and GPU modification of pages in a contigious 16MB virtual address region.
|
||||
* Information is stored in bitsets for spacial locality and fast update of single pages.
|
||||
@@ -126,6 +142,59 @@ public:
|
||||
UpdateProtection<false, false>();
|
||||
}
|
||||
|
||||
/// bbport: keep the page a red-zone generator just wrote permanently writable. Windows x64
|
||||
/// has no System V red zone: every exception dispatch writes its CONTEXT below RSP and
|
||||
/// destroys the generator's locals, so each additional write-tracking fault on the same page
|
||||
/// is a chance to corrupt the generator (the REDZONE GUARD HIT stutter). Pinned pages count
|
||||
/// as CPU modified forever, so they are uploaded whenever bound instead of faulting again.
|
||||
/// `size` is the faulting access size; only the touched page is pinned.
|
||||
void MakeSticky(VAddr fault_addr, u64 size) {
|
||||
if (BbToggle::Disabled2(BbToggle::StickyFaultPages)) {
|
||||
return;
|
||||
}
|
||||
const size_t offset = fault_addr - cpu_addr;
|
||||
const size_t start_page = SanitizeAddress(offset) / TRACKER_BYTES_PER_PAGE;
|
||||
const size_t end_page =
|
||||
Common::DivCeil(SanitizeAddress(offset + size), TRACKER_BYTES_PER_PAGE);
|
||||
if (start_page >= NUM_PAGES_PER_REGION || end_page <= start_page) {
|
||||
return;
|
||||
}
|
||||
RegionBits add(~sticky, start_page, end_page);
|
||||
if (add.None()) {
|
||||
return;
|
||||
}
|
||||
u32 num_new = 0;
|
||||
for (const auto& [start, end] : add) {
|
||||
num_new += static_cast<u32>(end - start);
|
||||
}
|
||||
const u32 budget = StickyFaultPageBudget();
|
||||
const u32 used = g_sticky_fault_pages.load(std::memory_order_relaxed);
|
||||
if (used >= budget) {
|
||||
return;
|
||||
}
|
||||
if (num_new > budget - used) {
|
||||
// Take as many of the new pages as the budget still allows.
|
||||
const u32 remaining_budget = budget - used;
|
||||
RegionBits limited{};
|
||||
u32 remaining = remaining_budget;
|
||||
for (const auto& [start, end] : add) {
|
||||
const size_t take = std::min<size_t>(end - start, remaining);
|
||||
limited.SetRange(start, start + take);
|
||||
remaining -= static_cast<u32>(take);
|
||||
if (remaining == 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
add = limited;
|
||||
num_new = remaining_budget - remaining;
|
||||
}
|
||||
sticky |= add;
|
||||
cpu |= add;
|
||||
g_sticky_fault_pages.fetch_add(num_new, std::memory_order_relaxed);
|
||||
BbStats::sticky_pages.fetch_add(num_new, std::memory_order_relaxed);
|
||||
UpdateProtection<false, false>();
|
||||
}
|
||||
|
||||
/**
|
||||
* Loop over each page in the given range, turn off those bits and notify the tracker if
|
||||
* needed. Call the given function on each turned off range.
|
||||
@@ -262,6 +331,12 @@ private:
|
||||
}
|
||||
|
||||
void KeepHotPagesModified(size_t start_page, size_t end_page) {
|
||||
// bbport: a pinned page must stay CPU modified; UpdateProtection (called right after this)
|
||||
// re-protects every page whose CPU bit was cleared, and the next write from the red-zone
|
||||
// generator would fault again on it.
|
||||
if (!sticky.None()) {
|
||||
cpu |= RegionBits(sticky, start_page, end_page);
|
||||
}
|
||||
if (num_hot == 0) {
|
||||
return;
|
||||
}
|
||||
@@ -282,6 +357,9 @@ private:
|
||||
u32 num_hot = 0;
|
||||
std::chrono::steady_clock::time_point hot_since{};
|
||||
|
||||
/// Pages the red-zone generator workaround keeps writable for the rest of the session.
|
||||
RegionBits sticky{};
|
||||
|
||||
PageManager* tracker;
|
||||
VAddr cpu_addr = 0;
|
||||
RegionBits cpu;
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
#include <miniz.h>
|
||||
|
||||
#include <condition_variable>
|
||||
#include <atomic>
|
||||
#include <functional>
|
||||
#include <future>
|
||||
#include <mutex>
|
||||
@@ -29,7 +30,7 @@
|
||||
namespace {
|
||||
|
||||
std::mutex submit_mutex{};
|
||||
u32 num_requests{};
|
||||
std::atomic<u32> num_requests{};
|
||||
std::condition_variable_any request_cv{};
|
||||
std::queue<std::packaged_task<void()>> req_queue{};
|
||||
std::mutex m_request{};
|
||||
@@ -44,13 +45,14 @@ namespace Storage {
|
||||
void ProcessIO(const std::stop_token& stoken) {
|
||||
Common::SetCurrentThreadName("shadPS4:PipelineCacheIO");
|
||||
|
||||
while (!stoken.stop_requested()) {
|
||||
for (;;) {
|
||||
{
|
||||
std::unique_lock lk{submit_mutex};
|
||||
Common::CondvarWait(request_cv, lk, stoken, [&] { return num_requests; });
|
||||
Common::CondvarWait(request_cv, lk, stoken, [&] { return num_requests.load() != 0; });
|
||||
}
|
||||
|
||||
if (stoken.stop_requested()) {
|
||||
// bbport: a stop request must not drop queued writes (Close() flushes through this queue).
|
||||
if (stoken.stop_requested() && num_requests == 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -138,6 +140,10 @@ void DataBase::Close() {
|
||||
return;
|
||||
}
|
||||
|
||||
// bbport: process everything that was queued (the driver pipeline cache in particular)
|
||||
// before the worker is stopped; stopping first would drop those writes.
|
||||
Flush();
|
||||
|
||||
io_worker.request_stop();
|
||||
io_worker.join();
|
||||
|
||||
@@ -150,6 +156,27 @@ void DataBase::Close() {
|
||||
LOG_INFO(Render, "Cache dumped");
|
||||
}
|
||||
|
||||
void DataBase::Flush() {
|
||||
if (!IsOpened()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The queue is FIFO, so this marker completing means every earlier write is on disk. A
|
||||
// promise is used because the worker itself calls get_future() on each packaged task.
|
||||
std::promise<void> marker;
|
||||
auto done = marker.get_future();
|
||||
{
|
||||
std::scoped_lock lock{m_request};
|
||||
req_queue.emplace([&marker] { marker.set_value(); });
|
||||
}
|
||||
{
|
||||
std::scoped_lock lk{submit_mutex};
|
||||
++num_requests;
|
||||
}
|
||||
request_cv.notify_one();
|
||||
done.wait();
|
||||
}
|
||||
|
||||
template <typename T>
|
||||
bool WriteVector(const BlobType type, std::filesystem::path&& path_, std::vector<T>&& v) {
|
||||
{
|
||||
|
||||
@@ -30,6 +30,10 @@ public:
|
||||
|
||||
void Open();
|
||||
void Close();
|
||||
/// bbport: blocks until every queued blob write has reached the disk. Close() uses it before
|
||||
/// stopping the IO worker, which otherwise could exit with writes still queued (the process
|
||||
/// leaves through _Exit, so nothing else flushes them either).
|
||||
void Flush();
|
||||
[[nodiscard]] bool IsOpened() const {
|
||||
return opened;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2024 shadPS4 Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
#include <mutex>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <boost/container/small_vector.hpp>
|
||||
|
||||
#include "shader_recompiler/info.h"
|
||||
#include "video_core/renderer_vulkan/vk_compute_pipeline.h"
|
||||
#include "video_core/renderer_vulkan/vk_instance.h"
|
||||
#include "video_core/renderer_vulkan/vk_pipeline_cache.h"
|
||||
#include "video_core/renderer_vulkan/vk_scheduler.h"
|
||||
|
||||
namespace Vulkan {
|
||||
@@ -84,9 +88,10 @@ ComputePipeline::ComputePipeline(const Instance& instance, Scheduler& scheduler,
|
||||
const auto device = instance.GetDevice();
|
||||
auto [descriptor_set_result, descriptor_set] =
|
||||
device.createDescriptorSetLayoutUnique(desc_layout_ci);
|
||||
ASSERT_MSG(descriptor_set_result == vk::Result::eSuccess,
|
||||
"Failed to create compute descriptor set layout: {}",
|
||||
vk::to_string(descriptor_set_result));
|
||||
if (descriptor_set_result != vk::Result::eSuccess) {
|
||||
throw std::runtime_error("Failed to create compute descriptor set layout: " +
|
||||
vk::to_string(descriptor_set_result));
|
||||
}
|
||||
desc_layout = std::move(descriptor_set);
|
||||
|
||||
const vk::DescriptorSetLayout set_layout = *desc_layout;
|
||||
@@ -97,8 +102,10 @@ ComputePipeline::ComputePipeline(const Instance& instance, Scheduler& scheduler,
|
||||
.pPushConstantRanges = &push_constants,
|
||||
};
|
||||
auto [layout_result, layout] = instance.GetDevice().createPipelineLayoutUnique(layout_info);
|
||||
ASSERT_MSG(layout_result == vk::Result::eSuccess,
|
||||
"Failed to create compute pipeline layout: {}", vk::to_string(layout_result));
|
||||
if (layout_result != vk::Result::eSuccess) {
|
||||
throw std::runtime_error("Failed to create compute pipeline layout: " +
|
||||
vk::to_string(layout_result));
|
||||
}
|
||||
pipeline_layout = std::move(layout);
|
||||
SetObjectName(device, *pipeline_layout, "Compute PipelineLayout {}", debug_str);
|
||||
|
||||
@@ -106,10 +113,22 @@ ComputePipeline::ComputePipeline(const Instance& instance, Scheduler& scheduler,
|
||||
.stage = shader_ci,
|
||||
.layout = *pipeline_layout,
|
||||
};
|
||||
auto [pipeline_result, pipe] =
|
||||
instance.GetDevice().createComputePipelineUnique(pipeline_cache, compute_pipeline_ci);
|
||||
ASSERT_MSG(pipeline_result == vk::Result::eSuccess, "Failed to create compute pipeline: {}",
|
||||
vk::to_string(pipeline_result));
|
||||
/* bbport: a VkPipelineCache needs external synchronization; the async workers share this one
|
||||
* with the GPU thread. Failure is raised to PipelineCache (which skips the pipeline) instead
|
||||
* of aborting the process. */
|
||||
vk::Result pipeline_result = vk::Result::eSuccess;
|
||||
vk::UniquePipeline pipe;
|
||||
{
|
||||
std::scoped_lock lock{PipelineCreationMutex()};
|
||||
auto created =
|
||||
instance.GetDevice().createComputePipelineUnique(pipeline_cache, compute_pipeline_ci);
|
||||
pipeline_result = created.result;
|
||||
pipe = std::move(created.value);
|
||||
}
|
||||
if (pipeline_result != vk::Result::eSuccess) {
|
||||
throw std::runtime_error("Failed to create compute pipeline: " +
|
||||
vk::to_string(pipeline_result));
|
||||
}
|
||||
pipeline = std::move(pipe);
|
||||
SetObjectName(device, *pipeline, "Compute Pipeline {}", debug_str);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
#include <algorithm>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <boost/container/small_vector.hpp>
|
||||
#include <mutex>
|
||||
@@ -14,6 +16,7 @@
|
||||
#include "video_core/renderer_vulkan/liverpool_to_vk.h"
|
||||
#include "video_core/renderer_vulkan/vk_graphics_pipeline.h"
|
||||
#include "video_core/renderer_vulkan/vk_instance.h"
|
||||
#include "video_core/renderer_vulkan/vk_pipeline_cache.h"
|
||||
#include "video_core/renderer_vulkan/vk_scheduler.h"
|
||||
#include "video_core/renderer_vulkan/vk_shader_util.h"
|
||||
|
||||
@@ -70,8 +73,10 @@ GraphicsPipeline::GraphicsPipeline(
|
||||
.pPushConstantRanges = &push_constants,
|
||||
};
|
||||
auto [layout_result, layout] = instance.GetDevice().createPipelineLayoutUnique(layout_info);
|
||||
ASSERT_MSG(layout_result == vk::Result::eSuccess,
|
||||
"Failed to create graphics pipeline layout: {}", vk::to_string(layout_result));
|
||||
if (layout_result != vk::Result::eSuccess) {
|
||||
throw std::runtime_error("Failed to create graphics pipeline layout: " +
|
||||
vk::to_string(layout_result));
|
||||
}
|
||||
pipeline_layout = std::move(layout);
|
||||
SetObjectName(device, *pipeline_layout, "Graphics PipelineLayout {}", debug_str);
|
||||
|
||||
@@ -231,7 +236,10 @@ GraphicsPipeline::GraphicsPipeline(
|
||||
.pCode = sdata.tcs.data(),
|
||||
};
|
||||
auto [res, mod] = instance.GetDevice().createShaderModuleUnique(shader_info);
|
||||
ASSERT_MSG(res == vk::Result::eSuccess, "Failed to compile SPIR-V shader: {}", vk::to_string(res));
|
||||
if (res != vk::Result::eSuccess) {
|
||||
throw std::runtime_error("Failed to compile SPIR-V shader: " +
|
||||
vk::to_string(res));
|
||||
}
|
||||
it = aux_tcs_cache.emplace(key, std::move(mod)).first;
|
||||
}
|
||||
return *it->second;
|
||||
@@ -271,7 +279,10 @@ GraphicsPipeline::GraphicsPipeline(
|
||||
.pCode = sdata.tes.data(),
|
||||
};
|
||||
auto [res, mod] = instance.GetDevice().createShaderModuleUnique(shader_info);
|
||||
ASSERT_MSG(res == vk::Result::eSuccess, "Failed to compile SPIR-V shader: {}", vk::to_string(res));
|
||||
if (res != vk::Result::eSuccess) {
|
||||
throw std::runtime_error("Failed to compile SPIR-V shader: " +
|
||||
vk::to_string(res));
|
||||
}
|
||||
it = aux_tes_cache.emplace(key, std::move(mod)).first;
|
||||
}
|
||||
return *it->second;
|
||||
@@ -308,7 +319,10 @@ GraphicsPipeline::GraphicsPipeline(
|
||||
.pCode = sdata.fragment.data(),
|
||||
};
|
||||
auto [res, mod] = instance.GetDevice().createShaderModuleUnique(shader_info);
|
||||
ASSERT_MSG(res == vk::Result::eSuccess, "Failed to compile SPIR-V shader: {}", vk::to_string(res));
|
||||
if (res != vk::Result::eSuccess) {
|
||||
throw std::runtime_error("Failed to compile SPIR-V shader: " +
|
||||
vk::to_string(res));
|
||||
}
|
||||
it = aux_frag_cache.emplace(key, std::move(mod)).first;
|
||||
}
|
||||
return *it->second;
|
||||
@@ -486,10 +500,22 @@ GraphicsPipeline::GraphicsPipeline(
|
||||
.layout = *pipeline_layout,
|
||||
};
|
||||
|
||||
auto [pipeline_result, pipe] =
|
||||
device.createGraphicsPipelineUnique(pipeline_cache, pipeline_info);
|
||||
ASSERT_MSG(pipeline_result == vk::Result::eSuccess, "Failed to create graphics pipeline: {}",
|
||||
vk::to_string(pipeline_result));
|
||||
/* bbport: Vulkan requires external synchronization on VkPipelineCache. The async pipeline
|
||||
* workers and the GPU thread create pipelines at the same time otherwise, which RADV answers
|
||||
* with ErrorUnknown; the old ASSERT_MSG then exited the process (exit code 23). Serializing
|
||||
* the call fixes the cause, and a failure is reported to PipelineCache instead of aborting. */
|
||||
vk::Result pipeline_result = vk::Result::eSuccess;
|
||||
vk::UniquePipeline pipe;
|
||||
{
|
||||
std::scoped_lock lock{PipelineCreationMutex()};
|
||||
auto created = device.createGraphicsPipelineUnique(pipeline_cache, pipeline_info);
|
||||
pipeline_result = created.result;
|
||||
pipe = std::move(created.value);
|
||||
}
|
||||
if (pipeline_result != vk::Result::eSuccess) {
|
||||
throw std::runtime_error("Failed to create graphics pipeline: " +
|
||||
vk::to_string(pipeline_result));
|
||||
}
|
||||
pipeline = std::move(pipe);
|
||||
SetObjectName(device, *pipeline, "Graphics Pipeline {}", debug_str);
|
||||
}
|
||||
@@ -599,8 +625,10 @@ void GraphicsPipeline::BuildDescSetLayout(bool preloading) {
|
||||
};
|
||||
auto [layout_result, layout] =
|
||||
instance.GetDevice().createDescriptorSetLayoutUnique(desc_layout_ci);
|
||||
ASSERT_MSG(layout_result == vk::Result::eSuccess,
|
||||
"Failed to create graphics descriptor set layout: {}", vk::to_string(layout_result));
|
||||
if (layout_result != vk::Result::eSuccess) {
|
||||
throw std::runtime_error("Failed to create graphics descriptor set layout: " +
|
||||
vk::to_string(layout_result));
|
||||
}
|
||||
desc_layout = std::move(layout);
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,11 @@ using Shader::HwStage;
|
||||
using Shader::Output;
|
||||
using Shader::SwStage;
|
||||
|
||||
std::mutex& PipelineCreationMutex() {
|
||||
static std::mutex mutex;
|
||||
return mutex;
|
||||
}
|
||||
|
||||
constexpr static auto SpirvVersion1_6 = 0x00010600U;
|
||||
|
||||
constexpr static std::array DescriptorHeapSizes = {
|
||||
@@ -372,9 +377,16 @@ PipelineCache::PipelineCache(const Instance& instance_, Scheduler& scheduler_,
|
||||
LOG_WARNING(Render, "Failed to create pipeline cache from stored driver data ({}): recreating empty",
|
||||
vk::to_string(cache_result));
|
||||
auto [empty_res, empty_cache] = instance.GetDevice().createPipelineCacheUnique({});
|
||||
ASSERT_MSG(empty_res == vk::Result::eSuccess, "Failed to create empty pipeline cache: {}",
|
||||
vk::to_string(empty_res));
|
||||
cache = std::move(empty_cache);
|
||||
if (empty_res != vk::Result::eSuccess) {
|
||||
/* bbport: VK_NULL_HANDLE is a legal pipeline cache, so pipeline creation can go on
|
||||
* without one (no driver-side caching) instead of exiting the process. */
|
||||
LOG_CRITICAL(Render,
|
||||
"Failed to create empty pipeline cache ({}); continuing without a "
|
||||
"driver pipeline cache",
|
||||
vk::to_string(empty_res));
|
||||
} else {
|
||||
cache = std::move(empty_cache);
|
||||
}
|
||||
}
|
||||
pipeline_cache = std::move(cache);
|
||||
|
||||
@@ -410,6 +422,10 @@ PipelineCompiler& PipelineCache::GetCompiler() {
|
||||
|
||||
|
||||
PipelineCache::~PipelineCache() {
|
||||
// bbport: the saver thread touches the device and the cache storage; it must be joined while
|
||||
// both are still alive. Sync() normally does this already; this covers teardown paths that
|
||||
// skip it.
|
||||
StopDriverCacheSaver();
|
||||
ClearAuxShaderCaches();
|
||||
}
|
||||
|
||||
@@ -545,6 +561,7 @@ const GraphicsPipeline* PipelineCache::GetGraphicsPipeline(const DrawIndirectPar
|
||||
const PreparedDraw* prepared,
|
||||
bool indirect) {
|
||||
PollShaderImport();
|
||||
MaybeSaveDriverPipelineCache();
|
||||
used_prepared = nullptr;
|
||||
NoteDrawTarget(liverpool->regs);
|
||||
if (prepared) {
|
||||
@@ -590,6 +607,13 @@ const GraphicsPipeline* PipelineCache::GetGraphicsPipeline(const DrawIndirectPar
|
||||
}
|
||||
// bbport BB_ASYNC_PIPELINES: being compiled on a worker, or to be.
|
||||
const bool skippable = AsyncSkippable(sel, indirect);
|
||||
/* bbport: a pipeline the driver refused `MaxPipelineFailures` times is not attempted again
|
||||
* (the failure was logged then). Its draws are dropped, as they already were while it was
|
||||
* pending; the rest of the frame keeps rendering. */
|
||||
if (const auto failed = graphics_failures.find(sel.graphics_key);
|
||||
failed != graphics_failures.end() && failed->second >= MaxPipelineFailures) {
|
||||
return nullptr;
|
||||
}
|
||||
if (const auto pending = pending_graphics.find(sel.graphics_key); pending != pending_graphics.end()) {
|
||||
const std::shared_ptr<PendingPipeline> job = pending->second;
|
||||
{
|
||||
@@ -630,9 +654,18 @@ const GraphicsPipeline* PipelineCache::GetGraphicsPipeline(const DrawIndirectPar
|
||||
runtime_infos = sel.runtime_infos, fetch = sel.fetch_shader,
|
||||
modules = sel.modules]() mutable {
|
||||
GraphicsPipeline::SerializationSupport sdata{};
|
||||
auto pipeline = std::make_unique<GraphicsPipeline>(
|
||||
instance, scheduler, desc_heap, profile, key, *pipeline_cache, infos,
|
||||
runtime_infos, fetch, modules, sdata, false);
|
||||
std::unique_ptr<GraphicsPipeline> pipeline;
|
||||
try {
|
||||
pipeline = std::make_unique<GraphicsPipeline>(
|
||||
instance, scheduler, desc_heap, profile, key, *pipeline_cache, infos,
|
||||
runtime_infos, fetch, modules, sdata, false);
|
||||
} catch (const std::exception& e) {
|
||||
/* bbport: the driver refused this pipeline. The job completes without one and
|
||||
* FinishPipeline() accounts and skips it; an exception must never escape a
|
||||
* worker thread (std::terminate). */
|
||||
LOG_WARNING(Render_Vulkan, "Async graphics pipeline {:#x} failed: {}",
|
||||
std::hash<GraphicsPipelineKey>{}(key), e.what());
|
||||
}
|
||||
std::scoped_lock lk{job->mutex};
|
||||
job->pipeline = std::move(pipeline);
|
||||
job->sdata = std::move(sdata);
|
||||
@@ -649,9 +682,24 @@ const GraphicsPipeline* PipelineCache::GetGraphicsPipeline(const DrawIndirectPar
|
||||
CompileTimer timer;
|
||||
|
||||
GraphicsPipeline::SerializationSupport sdata{};
|
||||
it.value() = std::make_unique<GraphicsPipeline>(
|
||||
instance, scheduler, desc_heap, profile, sel.graphics_key, *pipeline_cache, sel.infos,
|
||||
sel.runtime_infos, sel.fetch_shader, sel.modules, sdata, false);
|
||||
try {
|
||||
it.value() = std::make_unique<GraphicsPipeline>(
|
||||
instance, scheduler, desc_heap, profile, sel.graphics_key, *pipeline_cache,
|
||||
sel.infos, sel.runtime_infos, sel.fetch_shader, sel.modules, sdata, false);
|
||||
} catch (const std::exception& e) {
|
||||
/* bbport: the driver refused the pipeline (see PipelineCreationMutex). The empty map
|
||||
* entry is removed and the draw skipped; the key is retried a bounded number of
|
||||
* times by the next draws, then left out. Previously this aborted the process. */
|
||||
graphics_pipelines.erase(sel.graphics_key);
|
||||
const u32 failures = ++graphics_failures[sel.graphics_key];
|
||||
if (failures <= MaxPipelineFailures) {
|
||||
LOG_WARNING(Render_Vulkan,
|
||||
"Graphics pipeline {:#x} failed to create (attempt {} of {}): {}; "
|
||||
"the draw is skipped",
|
||||
pipeline_hash, failures, u32{MaxPipelineFailures}, e.what());
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
RegisterPipelineData(sel.graphics_key, pipeline_hash, sdata);
|
||||
++num_new_pipelines;
|
||||
@@ -671,6 +719,19 @@ const GraphicsPipeline* PipelineCache::GetGraphicsPipeline(const DrawIndirectPar
|
||||
|
||||
const GraphicsPipeline* PipelineCache::FinishPipeline(const GraphicsPipelineKey& key,
|
||||
PendingPipeline& job) {
|
||||
if (!job.pipeline) {
|
||||
/* bbport: the pipeline could not be created (the creator already logged the driver
|
||||
* error). Account it and let the next draws retry up to the cap; a null pipeline is
|
||||
* never stored, so a later attempt is possible and no draw dereferences it. */
|
||||
const u32 failures = ++graphics_failures[key];
|
||||
if (failures <= MaxPipelineFailures) {
|
||||
LOG_WARNING(Render_Vulkan,
|
||||
"Graphics pipeline {:#x} is unavailable (attempt {} of {}): the draw is "
|
||||
"skipped",
|
||||
std::hash<GraphicsPipelineKey>{}(key), failures, u32{MaxPipelineFailures});
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
const auto pipeline_hash = std::hash<GraphicsPipelineKey>{}(key);
|
||||
if (!job.saved) {
|
||||
RegisterPipelineData(key, pipeline_hash, job.sdata);
|
||||
@@ -683,6 +744,7 @@ const GraphicsPipeline* PipelineCache::FinishPipeline(const GraphicsPipelineKey&
|
||||
}
|
||||
}
|
||||
}
|
||||
graphics_failures.erase(key);
|
||||
auto& slot = graphics_pipelines[key];
|
||||
slot = std::move(job.pipeline);
|
||||
return slot.get();
|
||||
@@ -755,9 +817,15 @@ bool PipelineCache::AsyncSkippable(const PipelineSelection& selection, bool indi
|
||||
}
|
||||
|
||||
const ComputePipeline* PipelineCache::GetComputePipeline() {
|
||||
MaybeSaveDriverPipelineCache();
|
||||
if (!RefreshComputeKey()) {
|
||||
return nullptr;
|
||||
}
|
||||
/// bbport: a compute pipeline the driver refused repeatedly is not attempted again.
|
||||
if (const auto failed = compute_failures.find(compute_key);
|
||||
failed != compute_failures.end() && failed->second >= MaxPipelineFailures) {
|
||||
return nullptr;
|
||||
}
|
||||
const auto [it, is_new] = compute_pipelines.try_emplace(compute_key);
|
||||
if (is_new) {
|
||||
const auto pipeline_hash = std::hash<ComputePipelineKey>{}(compute_key);
|
||||
@@ -765,9 +833,23 @@ const ComputePipeline* PipelineCache::GetComputePipeline() {
|
||||
CompileTimer timer;
|
||||
|
||||
ComputePipeline::SerializationSupport sdata{};
|
||||
it.value() = std::make_unique<ComputePipeline>(instance, scheduler, desc_heap, profile,
|
||||
*pipeline_cache, compute_key, *sel.infos[0],
|
||||
sel.modules[0], sdata, false);
|
||||
try {
|
||||
it.value() = std::make_unique<ComputePipeline>(
|
||||
instance, scheduler, desc_heap, profile, *pipeline_cache, compute_key,
|
||||
*sel.infos[0], sel.modules[0], sdata, false);
|
||||
} catch (const std::exception& e) {
|
||||
/* bbport: the driver refused the pipeline. Remove the empty entry, account it and
|
||||
* skip the dispatch; a bounded number of later dispatches retries it. */
|
||||
compute_pipelines.erase(compute_key);
|
||||
const u32 failures = ++compute_failures[compute_key];
|
||||
if (failures <= MaxPipelineFailures) {
|
||||
LOG_WARNING(Render_Vulkan,
|
||||
"Compute pipeline {:#x} failed to create (attempt {} of {}): {}; "
|
||||
"the dispatch is skipped",
|
||||
pipeline_hash, failures, u32{MaxPipelineFailures}, e.what());
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
RegisterPipelineData(compute_key, sdata);
|
||||
++num_new_pipelines;
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#pragma once
|
||||
|
||||
#include <condition_variable>
|
||||
#include <chrono>
|
||||
#include <deque>
|
||||
#include <functional>
|
||||
#include <future>
|
||||
@@ -58,6 +59,11 @@ class Instance;
|
||||
class Scheduler;
|
||||
class ShaderCache;
|
||||
|
||||
/// bbport: Vulkan requires external synchronization on VkPipelineCache. The BB_ASYNC_PIPELINES
|
||||
/// workers and the GPU thread create pipelines at the same time otherwise, which RADV answers
|
||||
/// with ErrorUnknown ("constant crashes 23"). One mutex: a pipeline creation is ~18 ms anyway.
|
||||
std::mutex& PipelineCreationMutex();
|
||||
|
||||
struct Program {
|
||||
struct Module {
|
||||
vk::ShaderModule module;
|
||||
@@ -255,6 +261,14 @@ private:
|
||||
|
||||
std::string GetDriverCacheKey() const;
|
||||
void SaveDriverPipelineCache();
|
||||
/// bbport: asks the driver-cache saver thread to write the native driver cache about once a
|
||||
/// minute when new pipelines exist. The window thread ends the process with std::_Exit(0), so
|
||||
/// the presenter destructor (the only other SaveDriverPipelineCache caller) never runs and the
|
||||
/// .vkp would never be written. No-op when nothing new was compiled. The dump (10+ MB) and the
|
||||
/// disk flush run on the saver thread, never on the GPU thread.
|
||||
void MaybeSaveDriverPipelineCache();
|
||||
void DriverCacheSaveLoop();
|
||||
void StopDriverCacheSaver();
|
||||
|
||||
private:
|
||||
const Instance& instance;
|
||||
@@ -287,6 +301,14 @@ private:
|
||||
PipelineSelection sel{}; ///< GPU thread selection state
|
||||
ComputePipelineKey compute_key{};
|
||||
u32 num_new_pipelines{}; // new pipelines added to the cache since the game start
|
||||
std::chrono::steady_clock::time_point last_driver_cache_save{};
|
||||
u32 num_new_pipelines_at_last_save{};
|
||||
/// bbport: the periodic .vkp dump runs here, off the GPU thread (see MaybeSaveDriverPipelineCache).
|
||||
std::mutex driver_save_mutex;
|
||||
std::condition_variable driver_save_cv;
|
||||
std::thread driver_save_thread;
|
||||
bool driver_save_pending = false;
|
||||
bool driver_save_stop = false;
|
||||
|
||||
// Only if Config::collectShadersForDebug()
|
||||
tsl::robin_map<vk::ShaderModule,
|
||||
@@ -312,6 +334,12 @@ private:
|
||||
const GraphicsPipeline* FinishPipeline(const GraphicsPipelineKey& key, PendingPipeline& job);
|
||||
tsl::robin_map<GraphicsPipelineKey, std::shared_ptr<PendingPipeline>> pending_graphics;
|
||||
u64 async_started = 0, async_skipped = 0, async_waited = 0; ///< statistics
|
||||
/// bbport: pipelines the driver refused to create. A key is attempted a bounded number of
|
||||
/// times and afterwards its draws are skipped: one bad shader/permutation must not exit the
|
||||
/// process and must not be retried every frame either. GPU thread only.
|
||||
static constexpr u32 MaxPipelineFailures = 32;
|
||||
tsl::robin_map<GraphicsPipelineKey, u32> graphics_failures;
|
||||
tsl::robin_map<ComputePipelineKey, u32> compute_failures;
|
||||
std::unique_ptr<PipelineCompiler> compiler; ///< last: its threads are joined first
|
||||
|
||||
struct ShaderImport;
|
||||
|
||||
@@ -85,6 +85,9 @@ void PipelineCache::StartShaderImport() {
|
||||
import_ready.store(true, std::memory_order_relaxed);
|
||||
}
|
||||
SaveProfile();
|
||||
// bbport: the import child validates <cache>/profile.bin before it imports any recipe, so the
|
||||
// profile has to be on disk (not just queued) before CreateProcess runs.
|
||||
store.Flush();
|
||||
std::string pack_str;
|
||||
const char* pack = bb_env("BB_SHADER_RECIPES");
|
||||
if (!pack || !*pack || !std::filesystem::exists(pack, ec)) {
|
||||
@@ -333,9 +336,9 @@ void PipelineCache::MergeShaderImport(bool at_start) {
|
||||
return;
|
||||
}
|
||||
state.frame = frame;
|
||||
const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(2);
|
||||
const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(at_start ? 5 : 2);
|
||||
const auto in_budget = [&] {
|
||||
return at_start || std::chrono::steady_clock::now() < deadline;
|
||||
return std::chrono::steady_clock::now() < deadline;
|
||||
};
|
||||
while (state.next_program < state.programs.size() && in_budget()) {
|
||||
MergeImportedProgram(state, state.next_program++);
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
#include "common/serdes.h"
|
||||
#include "common/scope_exit.h"
|
||||
#include "common/thread.h"
|
||||
#include "core/emulator_settings.h"
|
||||
#include "shader_recompiler/frontend/fetch_shader.h"
|
||||
#include "shader_recompiler/info.h"
|
||||
@@ -9,15 +11,27 @@
|
||||
#include "video_core/renderer_vulkan/vk_instance.h"
|
||||
#include "video_core/renderer_vulkan/vk_pipeline_cache.h"
|
||||
#include <chrono>
|
||||
#include <filesystem>
|
||||
#include <future>
|
||||
#include <string>
|
||||
#include <unordered_set>
|
||||
#include "common/hash.h"
|
||||
#include "video_core/renderer_vulkan/vk_shader_util.h"
|
||||
#include "video_core/renderer_vulkan/vk_pipeline_serialization.h"
|
||||
|
||||
namespace Serialization {
|
||||
/* You should increment versions below once corresponding serialization scheme is changed. */
|
||||
static constexpr u32 ShaderBinaryVersion = 25u; // bbport: modernized recompiler & fast shader math (Phase 2)
|
||||
static constexpr u32 ShaderMetaVersion = 8u; // the object motion buffer addresses
|
||||
static constexpr u32 PipelineKeyVersion = 5u; // bbport: Info layout (ImageResource::needs_native)
|
||||
/* bbport: the versions live in vk_pipeline_serialization.h (PipelineStore) so the runtime, the
|
||||
* shader-rebuild tool and the recipe importer cannot drift apart. When they did, the rebuild tool
|
||||
* rewrote profile.bin to its own version after every start and WarmUp wiped the cache next time. */
|
||||
static_assert(Vulkan::PipelineStore::ShaderBinaryVersion == 25u,
|
||||
"modernized recompiler & fast shader math (Phase 2)");
|
||||
static_assert(Vulkan::PipelineStore::ShaderMetaVersion == 8u,
|
||||
"the object motion buffer addresses");
|
||||
static_assert(Vulkan::PipelineStore::PipelineKeyVersion == 5u,
|
||||
"Info layout (ImageResource::needs_native)");
|
||||
static constexpr u32 ShaderBinaryVersion = Vulkan::PipelineStore::ShaderBinaryVersion;
|
||||
static constexpr u32 ShaderMetaVersion = Vulkan::PipelineStore::ShaderMetaVersion;
|
||||
static constexpr u32 PipelineKeyVersion = Vulkan::PipelineStore::PipelineKeyVersion;
|
||||
} // namespace Serialization
|
||||
|
||||
namespace Vulkan {
|
||||
@@ -186,11 +200,22 @@ bool PipelineCache::LoadComputePipeline(Serialization::Archive& ar) {
|
||||
}
|
||||
|
||||
const auto [it, is_new] = compute_pipelines.try_emplace(compute_key);
|
||||
ASSERT(is_new);
|
||||
if (!is_new) {
|
||||
return false;
|
||||
}
|
||||
|
||||
it.value() =
|
||||
std::make_unique<ComputePipeline>(instance, scheduler, desc_heap, profile, *pipeline_cache,
|
||||
compute_key, *sel.infos[0], sel.modules[0], sdata, true);
|
||||
try {
|
||||
it.value() = std::make_unique<ComputePipeline>(
|
||||
instance, scheduler, desc_heap, profile, *pipeline_cache, compute_key, *sel.infos[0],
|
||||
sel.modules[0], sdata, true);
|
||||
} catch (const std::exception& e) {
|
||||
/* bbport: a cached pipeline the driver refuses must not abort startup (the constructor
|
||||
* now throws instead of asserting). Drop the entry; the shader compiles on demand. */
|
||||
compute_pipelines.erase(compute_key);
|
||||
LOG_WARNING(Render_Vulkan, "Cached compute pipeline {:#x} failed to create: {}",
|
||||
compute_key.value, e.what());
|
||||
return false;
|
||||
}
|
||||
|
||||
sel.infos.fill(nullptr);
|
||||
sel.modules.fill(nullptr);
|
||||
@@ -306,11 +331,21 @@ bool PipelineCache::LoadGraphicsPipeline(Serialization::Archive& ar) {
|
||||
}
|
||||
|
||||
const auto [it, is_new] = graphics_pipelines.try_emplace(sel.graphics_key);
|
||||
ASSERT(is_new);
|
||||
if (!is_new) {
|
||||
return false;
|
||||
}
|
||||
|
||||
it.value() = std::make_unique<GraphicsPipeline>(
|
||||
instance, scheduler, desc_heap, profile, sel.graphics_key, *pipeline_cache, sel.infos,
|
||||
sel.runtime_infos, sel.fetch_shader, sel.modules, sdata, true);
|
||||
try {
|
||||
it.value() = std::make_unique<GraphicsPipeline>(
|
||||
instance, scheduler, desc_heap, profile, sel.graphics_key, *pipeline_cache, sel.infos,
|
||||
sel.runtime_infos, sel.fetch_shader, sel.modules, sdata, true);
|
||||
} catch (const std::exception& e) {
|
||||
/* bbport: a cached pipeline the driver refuses must not abort startup (the constructor
|
||||
* now throws instead of asserting). Drop the entry; the shader compiles on demand. */
|
||||
graphics_pipelines.erase(sel.graphics_key);
|
||||
LOG_WARNING(Render_Vulkan, "Cached graphics pipeline failed to create: {}", e.what());
|
||||
return false;
|
||||
}
|
||||
|
||||
sel.infos.fill(nullptr);
|
||||
sel.modules.fill(nullptr);
|
||||
@@ -328,6 +363,22 @@ bool PipelineCache::LoadPipelineStage(Serialization::Archive& ar, size_t stage)
|
||||
return false;
|
||||
}
|
||||
|
||||
// bbport: the permutation index is only the name of the module in the store, not the
|
||||
// shader's identity. Different sessions appended permutations in the order the game first
|
||||
// used them, so the same specialization can be stored under another index. An equal
|
||||
// specialization is the same SPIR-V, so reuse the module already in memory and keep the
|
||||
// pipeline; refusing it dropped hundreds of otherwise valid preloads every launch.
|
||||
auto it_pgm = program_cache.find(program->info.pgm_hash);
|
||||
if (it_pgm != program_cache.end()) {
|
||||
Program* live = it_pgm.value().get();
|
||||
const auto it = std::ranges::find(live->modules, spec, &Program::Module::spec);
|
||||
if (it != live->modules.end()) {
|
||||
sel.infos[stage] = &live->info;
|
||||
sel.modules[stage] = it->module;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<u32> spv{};
|
||||
Storage::DataBase::Instance().Load(Storage::BlobType::ShaderBinary,
|
||||
fmt::format("{:#018x}_{}", program->info.pgm_hash, perm_idx),
|
||||
@@ -338,43 +389,30 @@ bool PipelineCache::LoadPipelineStage(Serialization::Archive& ar, size_t stage)
|
||||
return false;
|
||||
}
|
||||
|
||||
// Permutation hash depends on shader variation index. To prevent collisions, we need insert it
|
||||
// at the exact position rather than append
|
||||
|
||||
vk::ShaderModule module{};
|
||||
|
||||
auto [it_pgm, new_program] = program_cache.try_emplace(program->info.pgm_hash);
|
||||
if (new_program) {
|
||||
module = CompileSPV(spv, instance.GetDevice());
|
||||
if (!module) {
|
||||
return false;
|
||||
}
|
||||
it_pgm.value() = std::move(program);
|
||||
} else {
|
||||
const auto& it = std::ranges::find(it_pgm.value()->modules, spec, &Program::Module::spec);
|
||||
if (it != it_pgm.value()->modules.end()) {
|
||||
// A matching permutation is valid only at its original index. A different index means
|
||||
// the store holds entries from more than one cache generation, so this pipeline is
|
||||
// left to compile at runtime.
|
||||
const auto idx = std::distance(it_pgm.value()->modules.begin(), it);
|
||||
if (perm_idx != idx) {
|
||||
LOG_WARNING(Render_Vulkan,
|
||||
"Cached permutation {} of {}_{:x} conflicts with index {}, skipping "
|
||||
"preload",
|
||||
perm_idx, program->info.hw_stage, program->info.pgm_hash, idx);
|
||||
return false;
|
||||
}
|
||||
module = it->module;
|
||||
} else {
|
||||
module = CompileSPV(spv, instance.GetDevice());
|
||||
if (!module) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
vk::ShaderModule module = CompileSPV(spv, instance.GetDevice());
|
||||
if (!module) {
|
||||
return false;
|
||||
}
|
||||
it_pgm.value()->InsertPermut(module, std::move(spec), perm_idx);
|
||||
|
||||
sel.infos[stage] = &it_pgm.value()->info;
|
||||
if (it_pgm == program_cache.end()) {
|
||||
Program* live = program.get();
|
||||
live->InsertPermut(module, std::move(spec), perm_idx);
|
||||
program_cache.emplace(live->info.pgm_hash, std::move(program));
|
||||
sel.infos[stage] = &live->info;
|
||||
} else {
|
||||
Program* live = it_pgm.value().get();
|
||||
// `spec` was built against the temporary program; point it at the live one before it is
|
||||
// stored (the previous code left a dangling Shader::Info in the module list).
|
||||
spec.info = &live->info;
|
||||
if (perm_idx < live->modules.size() && live->modules[perm_idx].spec.Valid()) {
|
||||
// The stored index is occupied by a different specialization. Appending keeps the
|
||||
// modules referenced by pipelines loaded earlier in this warm-up valid.
|
||||
live->AddPermut(module, std::move(spec));
|
||||
} else {
|
||||
live->InsertPermut(module, std::move(spec), perm_idx);
|
||||
}
|
||||
sel.infos[stage] = &live->info;
|
||||
}
|
||||
sel.modules[stage] = module;
|
||||
|
||||
return true;
|
||||
@@ -389,6 +427,79 @@ void PipelineCache::SaveProfile() {
|
||||
Storage::DataBase::Instance().Save(Storage::BlobType::ShaderProfile, "profile", std::move(data));
|
||||
}
|
||||
|
||||
namespace {
|
||||
/* bbport: metas stamped with a version this build cannot load (LoadShaderMeta rejects both a
|
||||
* different meta and binary version) are dead weight. They are left behind by builds/tools that
|
||||
* wrote an older constant; the keys that referenced them are dropped on the first warm-up that
|
||||
* sees them. Delete the metas and their companion .spv/.in blobs so the cache converges instead
|
||||
* of keeping dead copies and making "N stale metas" look like an active writer. A companion is
|
||||
* named <pgm_hash>_<perm_idx>, and its meta's file name is exactly HashCombine(pgm_hash,
|
||||
* perm_idx), so it is orphaned precisely when that hash is not one of the live metas. */
|
||||
void PurgeStaleShaderMetas() {
|
||||
auto& db = Storage::DataBase::Instance();
|
||||
if (!db.IsOpened() || EmulatorSettings.IsPipelineCacheArchived()) {
|
||||
return;
|
||||
}
|
||||
|
||||
std::unordered_set<u64> live_metas;
|
||||
const u32 removed = db.ForEachBlob(
|
||||
Storage::BlobType::ShaderMeta, [&](std::vector<u8>&& data) {
|
||||
if (data.size() < 16) {
|
||||
return false;
|
||||
}
|
||||
u32 meta_version{};
|
||||
u32 binary_version{};
|
||||
u64 name{};
|
||||
std::memcpy(&meta_version, data.data() + 0, sizeof(meta_version));
|
||||
std::memcpy(&binary_version, data.data() + 4, sizeof(binary_version));
|
||||
std::memcpy(&name, data.data() + 8, sizeof(name));
|
||||
if (meta_version != Serialization::ShaderMetaVersion ||
|
||||
binary_version != Serialization::ShaderBinaryVersion) {
|
||||
return false;
|
||||
}
|
||||
live_metas.insert(name);
|
||||
return true;
|
||||
});
|
||||
if (removed == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
u32 companions{};
|
||||
std::error_code ec;
|
||||
for (const auto& entry : std::filesystem::directory_iterator{db.Directory(), ec}) {
|
||||
if (!entry.is_regular_file(ec)) {
|
||||
continue;
|
||||
}
|
||||
const auto& path = entry.path();
|
||||
const auto ext = path.extension().string();
|
||||
if (ext != ".spv" && ext != ".in") {
|
||||
continue;
|
||||
}
|
||||
const auto stem = path.stem().string();
|
||||
const auto sep = stem.rfind('_');
|
||||
if (sep == std::string::npos) {
|
||||
continue;
|
||||
}
|
||||
u64 pgm_hash{};
|
||||
u64 perm_idx{};
|
||||
try {
|
||||
pgm_hash = std::stoull(stem.substr(0, sep), nullptr, 16);
|
||||
perm_idx = std::stoull(stem.substr(sep + 1), nullptr, 10);
|
||||
} catch (const std::exception&) {
|
||||
continue;
|
||||
}
|
||||
if (!live_metas.contains(HashCombine(pgm_hash, perm_idx))) {
|
||||
companions += std::filesystem::remove(path, ec) ? 1 : 0;
|
||||
}
|
||||
}
|
||||
LOG_WARNING(Render,
|
||||
"Removed {} shader metas stamped with an unsupported version and {} orphaned "
|
||||
"shader blobs; every meta left is version {}/{}",
|
||||
removed, companions, Serialization::ShaderMetaVersion,
|
||||
Serialization::ShaderBinaryVersion);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void PipelineCache::WarmUp() {
|
||||
if (!EmulatorSettings.IsPipelineCacheEnabled()) {
|
||||
return;
|
||||
@@ -416,27 +527,66 @@ void PipelineCache::WarmUp() {
|
||||
}
|
||||
Shader::Profile cached_profile{};
|
||||
bool valid = false;
|
||||
bool have_cached = false;
|
||||
bool stale_version_header = false;
|
||||
/* bbport: older builds of the shader-rebuild tool stamped profile.bin with their own copy of
|
||||
* the binary version (24) while the runtime wrote 25. The profile contents and the meta/key
|
||||
* versions are still current in that case, and every individual .meta is version-checked by
|
||||
* LoadShaderMeta, so the header can be updated in place instead of deleting a valid cache.
|
||||
* This stops the launcher<->WarmUp version flip from wiping all shaders on every launch. */
|
||||
constexpr u32 StaleToolBinaryVersion = 24u;
|
||||
if (profile_data.size() == 16 + sizeof(Shader::Profile)) {
|
||||
u32 hdr[4];
|
||||
std::memcpy(hdr, profile_data.data(), sizeof(hdr));
|
||||
if (hdr[0] == CacheMagic &&
|
||||
hdr[1] == Serialization::ShaderBinaryVersion &&
|
||||
(hdr[1] == Serialization::ShaderBinaryVersion ||
|
||||
hdr[1] == StaleToolBinaryVersion) &&
|
||||
hdr[2] == Serialization::ShaderMetaVersion &&
|
||||
hdr[3] == Serialization::PipelineKeyVersion) {
|
||||
std::memcpy(&cached_profile, profile_data.data() + 16, sizeof(cached_profile));
|
||||
have_cached = true;
|
||||
valid = (cached_profile == profile);
|
||||
stale_version_header = valid && hdr[1] != Serialization::ShaderBinaryVersion;
|
||||
}
|
||||
} else if (profile_data.size() == sizeof(Shader::Profile)) {
|
||||
std::memcpy(&cached_profile, profile_data.data(), sizeof(cached_profile));
|
||||
have_cached = true;
|
||||
valid = (cached_profile == profile);
|
||||
}
|
||||
if (!valid) {
|
||||
/* bbport: name the fields that differ. A wipe leaves no trace otherwise, so a cache that
|
||||
* is rebuilt on every launch cannot be told apart from one that is merely cold. */
|
||||
if (have_cached) {
|
||||
const auto* cached = reinterpret_cast<const u8*>(&cached_profile);
|
||||
const auto* current = reinterpret_cast<const u8*>(&profile);
|
||||
std::string diff;
|
||||
for (size_t i = 0; i < sizeof(Shader::Profile); ++i) {
|
||||
if (cached[i] != current[i]) {
|
||||
if (!diff.empty()) {
|
||||
diff += ", ";
|
||||
}
|
||||
diff += fmt::format("[{}] {:#04x} -> {:#04x}", i, cached[i], current[i]);
|
||||
}
|
||||
}
|
||||
LOG_WARNING(Render, "Cached shader profile differs from this system: {}", diff);
|
||||
} else {
|
||||
LOG_WARNING(Render,
|
||||
"Cached shader profile is unreadable ({} bytes, versions or magic differ)",
|
||||
profile_data.size());
|
||||
}
|
||||
LOG_WARNING(Render, "Pipeline cache isn't compatible with current system: rebuilding it");
|
||||
Storage::DataBase::Instance().Clear();
|
||||
Storage::DataBase::Instance().FinishPreload();
|
||||
SaveProfile();
|
||||
return;
|
||||
}
|
||||
if (stale_version_header) {
|
||||
LOG_INFO(Render,
|
||||
"Cached shader profile matches this system; updating its version header to {} "
|
||||
"without rebuilding the cache",
|
||||
Serialization::ShaderBinaryVersion);
|
||||
save_profile();
|
||||
}
|
||||
|
||||
const char* env_warmup = std::getenv("BB_WARMUP_ASYNC");
|
||||
const bool warmup_async = !env_warmup || (std::strcmp(env_warmup, "0") != 0);
|
||||
@@ -444,6 +594,7 @@ void PipelineCache::WarmUp() {
|
||||
u32 num_pipelines{};
|
||||
u32 num_total_pipelines{};
|
||||
u32 num_removed{};
|
||||
u32 num_reused_permutations{};
|
||||
|
||||
if (!warmup_async) {
|
||||
try {
|
||||
@@ -550,42 +701,44 @@ void PipelineCache::WarmUp() {
|
||||
out_info = &program_ptr->info;
|
||||
spec.info = &program_ptr->info;
|
||||
|
||||
// A permutation index is only the name of the module in the store; the specialization
|
||||
// is the shader's identity. Earlier sessions appended permutations in a different
|
||||
// order, so an equal specialization can be staged under another index. Reuse it and
|
||||
// keep the pipeline; this used to discard several hundred preloads per launch.
|
||||
auto it = std::ranges::find_if(staged_permutations, [&](const StagedPermut& p) {
|
||||
return p.program == program_ptr && p.spec == spec;
|
||||
});
|
||||
if (it != staged_permutations.end()) {
|
||||
if (it->perm_idx != perm_idx) {
|
||||
LOG_WARNING(Render_Vulkan,
|
||||
"Cached permutation {} of {}_{:x} conflicts with index {}, skipping preload",
|
||||
perm_idx, program_ptr->info.hw_stage, program_ptr->info.pgm_hash, it->perm_idx);
|
||||
return false;
|
||||
++num_reused_permutations;
|
||||
}
|
||||
out_future = it->future;
|
||||
} else {
|
||||
auto conflict_it = std::ranges::find_if(staged_permutations, [&](const StagedPermut& p) {
|
||||
return p.program == program_ptr && p.perm_idx == perm_idx;
|
||||
});
|
||||
if (conflict_it != staged_permutations.end()) {
|
||||
LOG_WARNING(Render_Vulkan,
|
||||
"Cached permutation index {} of {}_{:x} already used with different spec, skipping preload",
|
||||
perm_idx, program_ptr->info.hw_stage, program_ptr->info.pgm_hash);
|
||||
return false;
|
||||
}
|
||||
|
||||
auto task = std::make_shared<std::packaged_task<vk::ShaderModule()>>(
|
||||
[device = instance.GetDevice(), spv_data = std::move(spv)]() {
|
||||
return CompileSPV(spv_data, device);
|
||||
});
|
||||
out_future = task->get_future().share();
|
||||
spv_futures.emplace(perm_hash, out_future);
|
||||
GetCompiler().Submit([task]() { (*task)(); });
|
||||
staged_permutations.push_back(StagedPermut{
|
||||
.program = program_ptr,
|
||||
.perm_idx = perm_idx,
|
||||
.spec = std::move(spec),
|
||||
.future = out_future,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
auto conflict_it = std::ranges::find_if(staged_permutations, [&](const StagedPermut& p) {
|
||||
return p.program == program_ptr && p.perm_idx == perm_idx;
|
||||
});
|
||||
if (conflict_it != staged_permutations.end()) {
|
||||
LOG_WARNING(Render_Vulkan,
|
||||
"Cached permutation index {} of {}_{:x} already used with different spec, skipping preload",
|
||||
perm_idx, program_ptr->info.hw_stage, program_ptr->info.pgm_hash);
|
||||
return false;
|
||||
}
|
||||
|
||||
auto task = std::make_shared<std::packaged_task<vk::ShaderModule()>>(
|
||||
[device = instance.GetDevice(), spv_data = std::move(spv)]() {
|
||||
return CompileSPV(spv_data, device);
|
||||
});
|
||||
out_future = task->get_future().share();
|
||||
spv_futures.emplace(perm_hash, out_future);
|
||||
GetCompiler().Submit([task]() { (*task)(); });
|
||||
staged_permutations.push_back(StagedPermut{
|
||||
.program = program_ptr,
|
||||
.perm_idx = perm_idx,
|
||||
.spec = std::move(spec),
|
||||
.future = out_future,
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -697,39 +850,112 @@ void PipelineCache::WarmUp() {
|
||||
}
|
||||
const auto [it, is_new] = compute_pipelines.try_emplace(entry.key);
|
||||
if (is_new) {
|
||||
it.value() = std::make_unique<ComputePipeline>(
|
||||
instance, scheduler, desc_heap, profile, *pipeline_cache, entry.key,
|
||||
*entry.info, mod, entry.sdata, true);
|
||||
try {
|
||||
it.value() = std::make_unique<ComputePipeline>(
|
||||
instance, scheduler, desc_heap, profile, *pipeline_cache, entry.key,
|
||||
*entry.info, mod, entry.sdata, true);
|
||||
} catch (const std::exception& e) {
|
||||
/* bbport: one driver-refused cached pipeline is skipped, not fatal. */
|
||||
compute_pipelines.erase(entry.key);
|
||||
LOG_WARNING(Render_Vulkan, "Cached compute pipeline failed to create: {}",
|
||||
e.what());
|
||||
continue;
|
||||
}
|
||||
++num_pipelines;
|
||||
}
|
||||
}
|
||||
|
||||
const std::array<Shader::RuntimeInfo, MaxShaderStages> empty_runtime_infos{};
|
||||
for (auto& entry : staged_graphics) {
|
||||
bool all_stages_ok = true;
|
||||
std::array<vk::ShaderModule, MaxShaderStages> modules{};
|
||||
for (size_t s = 0; s < MaxShaderStages; ++s) {
|
||||
if (entry.futures[s].valid()) {
|
||||
modules[s] = entry.futures[s].get();
|
||||
if (!modules[s]) {
|
||||
all_stages_ok = false;
|
||||
break;
|
||||
if (warmup_async) {
|
||||
for (auto& entry : staged_graphics) {
|
||||
if (graphics_pipelines.find(entry.key) != graphics_pipelines.end() ||
|
||||
pending_graphics.find(entry.key) != pending_graphics.end()) {
|
||||
continue;
|
||||
}
|
||||
auto job = std::make_shared<PendingPipeline>();
|
||||
job->saved = true;
|
||||
job->sdata = entry.sdata;
|
||||
job->make = [this, key = entry.key, infos = entry.infos,
|
||||
fetch = entry.fetch_shader, futures = entry.futures,
|
||||
sdata = entry.sdata]() mutable -> std::unique_ptr<GraphicsPipeline> {
|
||||
std::array<vk::ShaderModule, MaxShaderStages> modules{};
|
||||
for (size_t s = 0; s < MaxShaderStages; ++s) {
|
||||
if (futures[s].valid()) {
|
||||
modules[s] = futures[s].get();
|
||||
if (!modules[s]) {
|
||||
return nullptr;
|
||||
}
|
||||
}
|
||||
}
|
||||
const std::array<Shader::RuntimeInfo, MaxShaderStages> empty_runtime_infos{};
|
||||
return std::make_unique<GraphicsPipeline>(
|
||||
instance, scheduler, desc_heap, profile, key, *pipeline_cache,
|
||||
infos, empty_runtime_infos, fetch, modules, sdata, true);
|
||||
};
|
||||
pending_graphics.emplace(entry.key, job);
|
||||
++num_pipelines;
|
||||
g_bb_background_pipelines_total.fetch_add(1, std::memory_order_relaxed);
|
||||
GetCompiler().Submit([job] {
|
||||
SCOPE_EXIT {
|
||||
g_bb_background_pipelines_done.fetch_add(1, std::memory_order_relaxed);
|
||||
};
|
||||
if (job->started.exchange(true)) {
|
||||
return;
|
||||
}
|
||||
std::unique_ptr<GraphicsPipeline> pipeline;
|
||||
try {
|
||||
pipeline = job->make();
|
||||
} catch (const std::exception&) {
|
||||
}
|
||||
std::unique_lock lk{job->mutex};
|
||||
job->pipeline = std::move(pipeline);
|
||||
job->done = true;
|
||||
job->cv.notify_all();
|
||||
});
|
||||
}
|
||||
} else {
|
||||
for (auto& entry : staged_graphics) {
|
||||
bool all_stages_ok = true;
|
||||
std::array<vk::ShaderModule, MaxShaderStages> modules{};
|
||||
for (size_t s = 0; s < MaxShaderStages; ++s) {
|
||||
if (entry.futures[s].valid()) {
|
||||
modules[s] = entry.futures[s].get();
|
||||
if (!modules[s]) {
|
||||
all_stages_ok = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!all_stages_ok) {
|
||||
continue;
|
||||
}
|
||||
const auto [it, is_new] = graphics_pipelines.try_emplace(entry.key);
|
||||
if (is_new) {
|
||||
it.value() = std::make_unique<GraphicsPipeline>(
|
||||
instance, scheduler, desc_heap, profile, entry.key, *pipeline_cache,
|
||||
entry.infos, empty_runtime_infos, entry.fetch_shader, modules, entry.sdata, true);
|
||||
++num_pipelines;
|
||||
if (!all_stages_ok) {
|
||||
continue;
|
||||
}
|
||||
const auto [it, is_new] = graphics_pipelines.try_emplace(entry.key);
|
||||
if (is_new) {
|
||||
try {
|
||||
it.value() = std::make_unique<GraphicsPipeline>(
|
||||
instance, scheduler, desc_heap, profile, entry.key, *pipeline_cache,
|
||||
entry.infos, empty_runtime_infos, entry.fetch_shader, modules,
|
||||
entry.sdata, true);
|
||||
} catch (const std::exception& e) {
|
||||
/* bbport: one driver-refused cached pipeline is skipped, not fatal. */
|
||||
graphics_pipelines.erase(entry.key);
|
||||
LOG_WARNING(Render_Vulkan,
|
||||
"Cached graphics pipeline failed to create: {}", e.what());
|
||||
continue;
|
||||
}
|
||||
++num_pipelines;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* bbport: drop metas left by builds/tools that wrote an older binary version. This runs after
|
||||
* the preload pass on purpose: LoadShaderMeta rejects an unsupported version silently, so the
|
||||
* keys that referenced those metas are dropped and their metas can then be deleted in one go.
|
||||
* Purging first would make the preload hit deleted files and log an io_file error per key.
|
||||
* Runs only when the profile above is compatible. */
|
||||
PurgeStaleShaderMetas();
|
||||
|
||||
sel.infos.fill(nullptr);
|
||||
sel.modules.fill(nullptr);
|
||||
sel.fetch_shader.reset();
|
||||
@@ -744,6 +970,12 @@ void PipelineCache::WarmUp() {
|
||||
LOG_WARNING(Render, "{} cached pipelines could not be loaded: removed {} keys, kept {}",
|
||||
num_total_pipelines - num_pipelines, num_removed, num_pipelines);
|
||||
}
|
||||
if (num_reused_permutations != 0) {
|
||||
LOG_INFO(Render,
|
||||
"{} cached shader stages reused a permutation already staged under a different "
|
||||
"index (same specialization, so the preload was kept)",
|
||||
num_reused_permutations);
|
||||
}
|
||||
|
||||
Storage::DataBase::Instance().FinishPreload();
|
||||
}
|
||||
@@ -760,7 +992,16 @@ void PipelineCache::SaveDriverPipelineCache() {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
auto [res, data] = instance.GetDevice().getPipelineCacheData(*pipeline_cache);
|
||||
vk::Result res{vk::Result::eSuccess};
|
||||
std::vector<u8> data;
|
||||
{
|
||||
/* Vulkan requires external synchronization on the VkPipelineCache handle, and the
|
||||
* async compiler workers create pipelines concurrently (see vk_graphics_pipeline.cpp). */
|
||||
std::scoped_lock lock{PipelineCreationMutex()};
|
||||
auto result = instance.GetDevice().getPipelineCacheData(*pipeline_cache);
|
||||
res = result.result;
|
||||
data = std::move(result.value);
|
||||
}
|
||||
if (res == vk::Result::eSuccess && !data.empty()) {
|
||||
LOG_INFO(Render, "Saving native Vulkan driver pipeline cache ({} bytes)", data.size());
|
||||
Storage::DataBase::Instance().Save(Storage::BlobType::PipelineBinary,
|
||||
@@ -771,7 +1012,64 @@ void PipelineCache::SaveDriverPipelineCache() {
|
||||
}
|
||||
}
|
||||
|
||||
void PipelineCache::MaybeSaveDriverPipelineCache() {
|
||||
if (num_new_pipelines == num_new_pipelines_at_last_save) {
|
||||
return;
|
||||
}
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
/* The first new pipeline is written out at once (a small, cheap dump) so even an early crash
|
||||
* leaves a cache behind; later dumps are spaced out so the .vkp loses at most a minute. */
|
||||
if (last_driver_cache_save.time_since_epoch().count() != 0 &&
|
||||
now - last_driver_cache_save < std::chrono::minutes(1)) {
|
||||
return;
|
||||
}
|
||||
last_driver_cache_save = now;
|
||||
num_new_pipelines_at_last_save = num_new_pipelines;
|
||||
/* bbport: this is called from the GPU thread for every draw. Hand the work to a saver thread:
|
||||
* getPipelineCacheData() copies the whole driver cache (10+ MB here) and the write/flush used
|
||||
* to block the GPU thread, producing a periodic hitch every minute. */
|
||||
std::scoped_lock lk{driver_save_mutex};
|
||||
if (driver_save_stop) {
|
||||
return;
|
||||
}
|
||||
if (!driver_save_thread.joinable()) {
|
||||
driver_save_thread = std::thread([this] { DriverCacheSaveLoop(); });
|
||||
}
|
||||
driver_save_pending = true;
|
||||
driver_save_cv.notify_one();
|
||||
}
|
||||
|
||||
void PipelineCache::DriverCacheSaveLoop() {
|
||||
Common::SetCurrentThreadName("bb:DriverCacheSave");
|
||||
std::unique_lock lk{driver_save_mutex};
|
||||
for (;;) {
|
||||
driver_save_cv.wait(lk, [&] { return driver_save_stop || driver_save_pending; });
|
||||
if (driver_save_stop) {
|
||||
return;
|
||||
}
|
||||
driver_save_pending = false;
|
||||
lk.unlock();
|
||||
SaveDriverPipelineCache();
|
||||
/* Make this dump durable here, off the GPU thread. The GPU thread only asked for it. */
|
||||
Storage::DataBase::Instance().Flush();
|
||||
lk.lock();
|
||||
}
|
||||
}
|
||||
|
||||
void PipelineCache::StopDriverCacheSaver() {
|
||||
{
|
||||
std::scoped_lock lk{driver_save_mutex};
|
||||
if (!driver_save_thread.joinable()) {
|
||||
return;
|
||||
}
|
||||
driver_save_stop = true;
|
||||
}
|
||||
driver_save_cv.notify_all();
|
||||
driver_save_thread.join();
|
||||
}
|
||||
|
||||
void PipelineCache::Sync() {
|
||||
StopDriverCacheSaver();
|
||||
SaveDriverPipelineCache();
|
||||
Storage::DataBase::Instance().Close();
|
||||
}
|
||||
|
||||
@@ -10,7 +10,11 @@
|
||||
namespace Vulkan {
|
||||
|
||||
namespace PipelineStore {
|
||||
static constexpr u32 ShaderBinaryVersion = 24u;
|
||||
/* bbport: single source of truth for the cache version. The runtime constants in
|
||||
* vk_pipeline_serialization.cpp used to be a separate copy, and when only one of them was
|
||||
* bumped the launcher rewrote profile.bin to the other value on every start. WarmUp then saw
|
||||
* a mismatching profile and wiped the whole cache, which is why shaders recompiled forever. */
|
||||
static constexpr u32 ShaderBinaryVersion = 25u;
|
||||
static constexpr u32 ShaderMetaVersion = 8u;
|
||||
static constexpr u32 PipelineKeyVersion = 5u;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
#include "video_core/renderer_vulkan/vk_rasterizer.h"
|
||||
#include <xxhash.h>
|
||||
#include "shim/bb_env.h"
|
||||
#include "shim/bbgpu_fault_hint.h"
|
||||
#include "video_core/renderer_vulkan/vk_gpu_profiler.h"
|
||||
#include "bbport_threads.h"
|
||||
#include "bbport_button_prompts.h"
|
||||
@@ -1024,10 +1025,12 @@ void Rasterizer::DrawRecord(const GraphicsPipeline* pipeline, const PreparedDraw
|
||||
motion_geometry = 0;
|
||||
|
||||
PrepareRenderState(pipeline);
|
||||
if (upscaler->Enabled() && std::popcount(pipeline->GetGraphicsKey().mrt_mask) == 1) {
|
||||
const u32 draw_colors = std::popcount(pipeline->GetGraphicsKey().mrt_mask);
|
||||
// Stencil-only draws (UI masks) too: their depth must become the UI depth.
|
||||
if (upscaler->Enabled() && (draw_colors == 1 || (draw_colors == 0 && db_desc.first))) {
|
||||
const auto& viewport = Regs().viewports[0];
|
||||
upscaler->OnDraw(pipeline->GetStage(Shader::SwStage::Vertex).pgm_hash,
|
||||
cb_descs[0].first, db_desc.first,
|
||||
draw_colors ? cb_descs[0].first : VideoCore::ImageId{}, db_desc.first,
|
||||
UiComposition::NativeViewport(viewport.xscale * 2, viewport.yscale * 2));
|
||||
}
|
||||
const PreparedDraw* draw_prepared = bind_prepared;
|
||||
@@ -1239,10 +1242,11 @@ void Rasterizer::DrawIndirectRecord(const GraphicsPipeline* pipeline, bool is_in
|
||||
motion_draw = false;
|
||||
motion_geometry = 0;
|
||||
PrepareRenderState(pipeline);
|
||||
if (upscaler->Enabled() && std::popcount(pipeline->GetGraphicsKey().mrt_mask) == 1) {
|
||||
const u32 draw_colors = std::popcount(pipeline->GetGraphicsKey().mrt_mask);
|
||||
if (upscaler->Enabled() && (draw_colors == 1 || (draw_colors == 0 && db_desc.first))) {
|
||||
const auto& viewport = Regs().viewports[0];
|
||||
upscaler->OnDraw(pipeline->GetStage(Shader::SwStage::Vertex).pgm_hash,
|
||||
cb_descs[0].first, db_desc.first,
|
||||
draw_colors ? cb_descs[0].first : VideoCore::ImageId{}, db_desc.first,
|
||||
UiComposition::NativeViewport(viewport.xscale * 2, viewport.yscale * 2));
|
||||
}
|
||||
if (!BindResources(pipeline)) {
|
||||
@@ -3306,6 +3310,12 @@ bool Rasterizer::OnWriteFault(VAddr addr, bool assume_locks) {
|
||||
return false;
|
||||
}
|
||||
buffer_cache.ExtendWriteFault(addr);
|
||||
if (BbFaultHint::redzone) {
|
||||
// A PS4 vertex generator keeps locals in the System V red zone, which the Windows
|
||||
// exception frame destroys on every handled fault. Keep this page writable so this
|
||||
// routine does not fault on it again.
|
||||
buffer_cache.MakeWriteFaultSticky(addr);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -26,26 +26,35 @@ namespace Vulkan {
|
||||
|
||||
std::mutex Scheduler::submit_mutex;
|
||||
|
||||
void ReportCommandOutsideChunk(const void* chunk, size_t chunk_used,
|
||||
const void* command) noexcept {
|
||||
/* Once per session: the same overwritten chunk would report on every
|
||||
* Execute call, and this path abandons the rest of the chunk anyway. */
|
||||
static std::atomic<bool> reported{false};
|
||||
if (reported.exchange(true, std::memory_order_relaxed)) {
|
||||
void ReportCommandOutsideChunk(const void* chunk, size_t chunk_used, const void* command,
|
||||
const char* reason) noexcept {
|
||||
/* Rate limited rather than once per session: a single overwritten chunk abandons the rest of
|
||||
* its list on every Execute, and the next ones carry the same defect. */
|
||||
static std::atomic<u32> reported{0};
|
||||
if (reported.fetch_add(1, std::memory_order_relaxed) >= 32) {
|
||||
return;
|
||||
}
|
||||
const auto base = reinterpret_cast<std::uintptr_t>(chunk);
|
||||
const auto addr = reinterpret_cast<std::uintptr_t>(command);
|
||||
LOG_CRITICAL(Render_Vulkan,
|
||||
"RecordChunk::Execute: command {:#x} is outside the chunk storage [{:#x}..{:#x}) "
|
||||
"(slot {}, used {}, capacity {}): dropped the rest of the chunk",
|
||||
static_cast<unsigned long long>(addr), static_cast<unsigned long long>(base),
|
||||
static_cast<unsigned long long>(base + chunk_used),
|
||||
"RecordChunk::Execute: {} for command {:#x} (slot {}, used {}, capacity {}): "
|
||||
"dropped the rest of the chunk",
|
||||
reason, static_cast<unsigned long long>(addr),
|
||||
static_cast<unsigned long long>(addr - base),
|
||||
static_cast<unsigned long long>(chunk_used),
|
||||
static_cast<unsigned long long>(RecordChunk::Capacity));
|
||||
}
|
||||
|
||||
void ReportRecordOutOfMemory() noexcept {
|
||||
static std::atomic<u32> reported{0};
|
||||
if (reported.fetch_add(1, std::memory_order_relaxed) >= 8) {
|
||||
return;
|
||||
}
|
||||
LOG_CRITICAL(Render_Vulkan,
|
||||
"Recording chunk is out of host memory: the affected draw is skipped instead of "
|
||||
"being recorded with data that was not copied");
|
||||
}
|
||||
|
||||
Scheduler::Scheduler(const Instance& instance, bool threaded_recording)
|
||||
: instance{instance}, work_semaphore{instance}, command_pool{instance, &work_semaphore} {
|
||||
// bbport: BB_VK_RECORD_THREAD=0 records on the calling thread.
|
||||
@@ -321,23 +330,29 @@ void Scheduler::KickRecording(bool force) {
|
||||
}
|
||||
// Callers kick where nobody holds the raw command buffer: deferral resumes.
|
||||
direct_mode = false;
|
||||
// Batches of tens of KiB keep the queue handoff cheap relative to the work it carries.
|
||||
if (!force && full_chunks.empty() && CurrentChunk().Size() < 32 * 1024) {
|
||||
return;
|
||||
}
|
||||
if (full_chunks.empty() && CurrentChunk().Empty()) {
|
||||
return;
|
||||
}
|
||||
// The chunk that takes the current one's place, taken first (AcquireChunk locks
|
||||
// recorder_mutex) and swapped in below: record_chunk is never null, not even for a fault
|
||||
// handler recording on this thread meanwhile (issue #100).
|
||||
std::unique_ptr<RecordChunk> next_record = record_chunk->Empty() ? nullptr : AcquireChunk();
|
||||
bool wake;
|
||||
bool wake = false;
|
||||
{
|
||||
std::scoped_lock lk{recorder_mutex};
|
||||
/* bbport: the same lock the recording path takes. Handing the current chunk to the
|
||||
* recorder while another thread is still pushing into it let the recorder reset `used`
|
||||
* and recycle the storage under that thread (#185, "randomly closes after 10-60 min"). */
|
||||
std::scoped_lock lk{record_mutex};
|
||||
// Batches of tens of KiB keep the queue handoff cheap relative to the work it carries.
|
||||
if (!force && full_chunks.empty() && CurrentChunk().Size() < 32 * 1024) {
|
||||
return;
|
||||
}
|
||||
if (full_chunks.empty() && CurrentChunk().Empty()) {
|
||||
return;
|
||||
}
|
||||
// The chunk that takes the current one's place, taken first (AcquireChunk locks
|
||||
// recorder_mutex) and swapped in below: record_chunk is never null, not even for a fault
|
||||
// handler recording on this thread meanwhile (issue #100).
|
||||
std::unique_ptr<RecordChunk> next_record =
|
||||
CurrentChunk().Empty() ? nullptr : AcquireChunk();
|
||||
std::scoped_lock recorder_lk{recorder_mutex};
|
||||
for (auto& chunk : full_chunks) {
|
||||
recorder_queue.push_back(std::move(chunk));
|
||||
}
|
||||
full_chunks.clear();
|
||||
if (next_record) {
|
||||
record_chunk.swap(next_record); // next_record: the chunk handed over
|
||||
recorder_queue.push_back(std::move(next_record));
|
||||
@@ -345,7 +360,6 @@ void Scheduler::KickRecording(bool force) {
|
||||
wake = recorder_sleeping;
|
||||
queued_chunks.store(recorder_queue.size(), std::memory_order_release);
|
||||
}
|
||||
full_chunks.clear();
|
||||
// A busy recorder picks the new chunks up by itself: waking it is a syscall per draw.
|
||||
if (wake) {
|
||||
recorder_cv.notify_one();
|
||||
|
||||
@@ -5,11 +5,13 @@
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cstddef>
|
||||
#include <cstring>
|
||||
#include <condition_variable>
|
||||
#include <deque>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <new>
|
||||
#include <span>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
@@ -579,7 +581,13 @@ using SubmitFunc = Common::UniqueFunction<void, SubmitInfo&>;
|
||||
/// a recorded command that is not inside its chunk's storage (the intrusive list was
|
||||
/// overwritten). Logs once per session; `chunk` is the chunk storage, `chunk_used` its high
|
||||
/// water mark (chunk slot index = command - chunk).
|
||||
void ReportCommandOutsideChunk(const void* chunk, size_t chunk_used, const void* command) noexcept;
|
||||
void ReportCommandOutsideChunk(const void* chunk, size_t chunk_used, const void* command,
|
||||
const char* reason) noexcept;
|
||||
|
||||
/// A recorded command could not be stored (host out of memory). The command is dropped, never
|
||||
/// run with data that was not copied: a dropped draw leaves a frame wrong, a freed span crashes
|
||||
/// the driver.
|
||||
void ReportRecordOutOfMemory() noexcept;
|
||||
|
||||
/// a block of deferred Vulkan commands. Commands are closures placed in
|
||||
/// fixed storage (no allocation per command) and run in order on the recording thread.
|
||||
@@ -587,46 +595,64 @@ class RecordChunk {
|
||||
public:
|
||||
static constexpr size_t Capacity = 128 * 1024;
|
||||
|
||||
/// Returns false (and leaves `func` untouched) when the chunk has no room.
|
||||
/// Stamp of a live command slot. Command storage is bump-allocated, so a slot is only ever
|
||||
/// written once; a stamp that no longer matches means the chunk memory was reused under a
|
||||
/// live command list. Checking it costs one load and catches what the address check cannot
|
||||
/// (#185: the "vtable" read was 0x756ad00, i.e. buffer-handle data in the slot).
|
||||
static constexpr std::uint32_t CommandMagic = 0x4242434Du; // "BBCM"
|
||||
|
||||
/// Records `func`. Only false when the host is out of memory; the command storage spills to
|
||||
/// the heap instead of failing, so a full chunk can never split data from its command.
|
||||
template <typename Func>
|
||||
bool Push(Func&& func) {
|
||||
using Command = TypedCommand<std::decay_t<Func>>;
|
||||
static_assert(sizeof(Command) <= Capacity, "recorded command is too large");
|
||||
const size_t offset = (used + alignof(Command) - 1) & ~(alignof(Command) - 1);
|
||||
if (offset + sizeof(Command) > Capacity) {
|
||||
auto* slot = static_cast<void*>(Allocate(sizeof(Command), alignof(Command)));
|
||||
if (!slot) [[unlikely]] {
|
||||
return false;
|
||||
}
|
||||
auto* command = new (storage + offset) Command(std::forward<Func>(func));
|
||||
auto* command = new (slot) Command(std::forward<Func>(func));
|
||||
if (last) {
|
||||
last->next = command;
|
||||
} else {
|
||||
first = command;
|
||||
}
|
||||
last = command;
|
||||
used = offset + sizeof(Command);
|
||||
PrefetchAhead();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Raw storage in the chunk for a command's variable-length data; null when full.
|
||||
/// Raw storage in the chunk for a command's variable-length data. Bump-allocated from the
|
||||
/// fixed block; the heap spill keeps it in the same chunk when the block is full, so the
|
||||
/// data can never be split from the command that reads it. Null only on host OOM.
|
||||
void* Allocate(size_t bytes, size_t align) {
|
||||
const size_t offset = (used + align - 1) & ~(align - 1);
|
||||
if (offset + bytes > Capacity) {
|
||||
return nullptr;
|
||||
if (bytes == 0) {
|
||||
return storage + used;
|
||||
}
|
||||
used = offset + bytes;
|
||||
PrefetchAhead();
|
||||
return storage + offset;
|
||||
if (align < alignof(std::max_align_t)) {
|
||||
align = alignof(std::max_align_t);
|
||||
}
|
||||
const size_t offset = (used + align - 1) & ~(align - 1);
|
||||
if (offset + bytes <= Capacity) {
|
||||
used = offset + bytes;
|
||||
PrefetchAhead();
|
||||
return storage + offset;
|
||||
}
|
||||
return AllocateSpill(bytes, align);
|
||||
}
|
||||
|
||||
void Execute(vk::CommandBuffer cmdbuf) {
|
||||
for (CommandBase* command = first; command;) {
|
||||
/* bbport: a command outside this chunk's storage means the list was overwritten
|
||||
* (phase-2 C8: the loop read command 0 at +0x1d7378 and 0x4803b9f9492 at +0x1d2c38,
|
||||
* both faulting on [command+0x10]). Two integer comparisons, no dereference, so it
|
||||
* runs unconditionally: no legitimate command is ever outside its own chunk. */
|
||||
* both faulting on [command+0x10]). The stamp check below additionally rejects a
|
||||
* slot whose memory was reused for data; both are integer comparisons on validated
|
||||
* memory, so they run unconditionally. */
|
||||
if (!InChunk(command)) {
|
||||
ReportCommandOutsideChunk(storage, used, command);
|
||||
ReportCommandOutsideChunk(storage, used, command, "outside the chunk storage");
|
||||
break;
|
||||
}
|
||||
if (command->magic != CommandMagic) {
|
||||
ReportCommandOutsideChunk(storage, used, command, "command stamp overwritten");
|
||||
break;
|
||||
}
|
||||
CommandBase* const next = command->next;
|
||||
@@ -636,6 +662,7 @@ public:
|
||||
}
|
||||
first = last = nullptr;
|
||||
used = 0;
|
||||
spill.clear();
|
||||
}
|
||||
|
||||
[[nodiscard]] bool Empty() const noexcept {
|
||||
@@ -657,9 +684,36 @@ private:
|
||||
}
|
||||
}
|
||||
|
||||
/// A block owned by this chunk for data/commands that did not fit in `storage`. Freed once
|
||||
/// the chunk has been executed, so anything it holds outlives every command recorded with
|
||||
/// it and never outlives the chunk.
|
||||
struct Spill {
|
||||
std::unique_ptr<std::byte[]> memory;
|
||||
std::byte* data = nullptr;
|
||||
size_t size = 0;
|
||||
};
|
||||
|
||||
void* AllocateSpill(size_t bytes, size_t align) {
|
||||
if (align > 64) {
|
||||
align = 64;
|
||||
}
|
||||
// One extra alignment unit so the returned pointer can be aligned by hand.
|
||||
auto memory = std::unique_ptr<std::byte[]>(new (std::nothrow) std::byte[bytes + align]);
|
||||
if (!memory) [[unlikely]] {
|
||||
return nullptr;
|
||||
}
|
||||
const auto base = reinterpret_cast<std::uintptr_t>(memory.get());
|
||||
const auto aligned = (base + (align - 1)) & ~static_cast<std::uintptr_t>(align - 1);
|
||||
spill.push_back(Spill{std::move(memory), reinterpret_cast<std::byte*>(aligned), bytes});
|
||||
return reinterpret_cast<void*>(aligned);
|
||||
}
|
||||
|
||||
struct CommandBase {
|
||||
virtual ~CommandBase() = default;
|
||||
virtual void Execute(vk::CommandBuffer cmdbuf) = 0;
|
||||
/// Checked before the virtual call; see CommandMagic.
|
||||
std::uint32_t magic = CommandMagic;
|
||||
std::uint32_t reserved = 0;
|
||||
CommandBase* next{};
|
||||
};
|
||||
template <typename Func>
|
||||
@@ -673,17 +727,28 @@ private:
|
||||
};
|
||||
|
||||
/// Whether `command` points at a command slot inside this chunk: the loop in Execute() is
|
||||
/// about to read its vtable, so the check is two integer comparisons and no dereference.
|
||||
/// about to read its vtable, so the check is integer comparisons and no dereference.
|
||||
[[nodiscard]] bool InChunk(const CommandBase* command) const noexcept {
|
||||
const auto base = reinterpret_cast<std::uintptr_t>(storage);
|
||||
const auto addr = reinterpret_cast<std::uintptr_t>(command);
|
||||
return addr >= base && addr + sizeof(CommandBase) <= base + used;
|
||||
if (addr >= base && addr + sizeof(CommandBase) <= base + used) {
|
||||
return true;
|
||||
}
|
||||
for (const auto& block : spill) {
|
||||
const auto start = reinterpret_cast<std::uintptr_t>(block.data);
|
||||
if (addr >= start && addr + sizeof(CommandBase) <= start + block.size) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
alignas(64) std::byte storage[Capacity];
|
||||
size_t used = 0;
|
||||
CommandBase* first{};
|
||||
CommandBase* last{};
|
||||
/// Rare: only a batch that crosses the end of `storage` uses it. Cleared by Execute().
|
||||
std::vector<Spill> spill;
|
||||
};
|
||||
class Scheduler {
|
||||
public:
|
||||
@@ -760,10 +825,20 @@ public:
|
||||
func(current_cmdbuf);
|
||||
return;
|
||||
}
|
||||
if (!CurrentChunk().Push(std::forward<Func>(func))) {
|
||||
RetireChunk();
|
||||
const bool pushed = CurrentChunk().Push(std::forward<Func>(func));
|
||||
ASSERT(pushed);
|
||||
/* bbport: the chunk is bookkeeping shared by the threads allowed to record (the GPU
|
||||
* command thread and the draw recording thread). Without this lock a RecordData() copy
|
||||
* or a RetireChunk() on one thread can land on a command another thread just pushed -
|
||||
* #185 read a buffer handle where the vtable of its command was. */
|
||||
std::scoped_lock lk{record_mutex};
|
||||
if (record_data_failed.load(std::memory_order_relaxed)) [[unlikely]] {
|
||||
// A RecordData() of this batch could not be copied: the command would read a span
|
||||
// that was never filled (or a null one). Skipping it leaves a frame wrong; running
|
||||
// it crashes the driver.
|
||||
return;
|
||||
}
|
||||
if (!CurrentChunk().Push(std::forward<Func>(func))) [[unlikely]] {
|
||||
ReportRecordOutOfMemory(); // host OOM only: Push() spills instead of failing
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -779,36 +854,47 @@ public:
|
||||
return direct_mode;
|
||||
}
|
||||
|
||||
/// Makes room for `bytes` of RecordData() plus the command that uses them in the current
|
||||
/// chunk: data and command must share a chunk, which is recycled once executed.
|
||||
/// Makes room for a batch of RecordData() plus the commands that read it, in one chunk.
|
||||
/// Must be called before the batch's first RecordData(): retirement is only safe at batch
|
||||
/// boundaries. Data and commands are never separated afterwards - RecordData() spills
|
||||
/// instead of retiring, and Print/Push() spills instead of failing.
|
||||
void ReserveRecordData(size_t bytes) {
|
||||
if (!IsRecordingDeferred()) {
|
||||
return;
|
||||
}
|
||||
ASSERT(bytes + 256 <= RecordChunk::Capacity);
|
||||
if (RecordChunk::Capacity - CurrentChunk().Size() < bytes + 256) {
|
||||
std::scoped_lock lk{record_mutex};
|
||||
record_data_failed.store(false, std::memory_order_relaxed);
|
||||
if (bytes + RecordCommandHeadroom > RecordChunk::Capacity) {
|
||||
// A batch larger than a chunk: retiring cannot make room, the spill carries it.
|
||||
return;
|
||||
}
|
||||
if (RecordChunk::Capacity - CurrentChunk().Size() < bytes + RecordCommandHeadroom) {
|
||||
RetireChunk();
|
||||
}
|
||||
}
|
||||
|
||||
/// Copies data into recording storage that lives until the command that uses it has
|
||||
/// been recorded (the same chunk). With threaded recording off, returns data itself.
|
||||
/// Copies data into recording storage that lives until the command that uses it has been
|
||||
/// recorded (the same chunk). With threaded recording off, returns data itself.
|
||||
template <typename T>
|
||||
std::span<const T> RecordData(std::span<const T> data) {
|
||||
if (!IsRecordingDeferred() || data.empty()) {
|
||||
return data;
|
||||
}
|
||||
const size_t bytes = data.size_bytes();
|
||||
if (RecordChunk::Capacity - CurrentChunk().Size() < bytes + 256) {
|
||||
RetireChunk();
|
||||
std::scoped_lock lk{record_mutex};
|
||||
/* bbport: this used to retire the chunk when the copy was close to the end. That split
|
||||
* the batch: the data stayed in the retired chunk (executed and recycled first) while
|
||||
* its command went to the next one, so the driver read freed memory (#185, "randomly
|
||||
* closes after 10-60 min": nvoglv64 [rcx+0x130] with rcx=0). The chunk now keeps the
|
||||
* copy - in its fixed block or in the spill that it frees only after Execute(). */
|
||||
auto* dst = static_cast<T*>(CurrentChunk().Allocate(data.size_bytes(), alignof(T)));
|
||||
if (!dst) [[unlikely]] {
|
||||
/* Host OOM: there is no safe span to hand the command, so this batch's commands
|
||||
* are dropped rather than run against a null/param pointer. */
|
||||
record_data_failed.store(true, std::memory_order_relaxed);
|
||||
ReportRecordOutOfMemory();
|
||||
return {};
|
||||
}
|
||||
auto* dst = static_cast<T*>(CurrentChunk().Allocate(bytes, alignof(T)));
|
||||
if (!dst) {
|
||||
RetireChunk();
|
||||
dst = static_cast<T*>(CurrentChunk().Allocate(bytes, alignof(T)));
|
||||
ASSERT(dst);
|
||||
}
|
||||
std::memcpy(dst, data.data(), bytes);
|
||||
std::memcpy(dst, data.data(), data.size_bytes());
|
||||
return {dst, data.size()};
|
||||
}
|
||||
|
||||
@@ -909,11 +995,18 @@ public:
|
||||
static std::mutex submit_mutex;
|
||||
|
||||
private:
|
||||
/// Data bytes a batch may need plus room for the Record() calls that read them. The old
|
||||
/// value (256) did not cover the two or three commands EmitVertexBuffers pushes after its
|
||||
/// data, so the last one could be pushed into a new chunk (#185).
|
||||
static constexpr size_t RecordCommandHeadroom = 4096;
|
||||
|
||||
void AllocateWorkerCommandBuffers();
|
||||
|
||||
std::unique_ptr<RecordChunk> AcquireChunk();
|
||||
|
||||
/// Moves the full current chunk aside; a new one takes its place (swapped in: never null).
|
||||
/// Callers must hold `record_mutex`: a chunk retired while another thread still records into
|
||||
/// it is executed (and recycled) under that thread's writes.
|
||||
void RetireChunk() {
|
||||
auto full = AcquireChunk();
|
||||
record_chunk.swap(full);
|
||||
@@ -963,6 +1056,12 @@ private:
|
||||
// bbport: threaded recording
|
||||
std::unique_ptr<RecordChunk> record_chunk;
|
||||
std::vector<std::unique_ptr<RecordChunk>> full_chunks;
|
||||
/// Serializes the chunk bump allocator and its hand-off to the recorder (see Record()).
|
||||
/// Order: record_mutex before recorder_mutex, never the other way round.
|
||||
std::mutex record_mutex;
|
||||
/// A RecordData() of the current batch could not be copied (host OOM); the batch's commands
|
||||
/// are dropped until the next ReserveRecordData().
|
||||
std::atomic<bool> record_data_failed{false};
|
||||
std::mutex recorder_mutex;
|
||||
std::condition_variable_any recorder_cv;
|
||||
std::condition_variable_any recorder_idle_cv;
|
||||
|
||||
@@ -135,24 +135,25 @@ std::optional<Translated> Translate(BbShaderCapture::Input& in, const Shader::Pr
|
||||
return out;
|
||||
}
|
||||
|
||||
std::vector<u8> RewriteMeta(std::vector<u8>&& meta, const std::vector<u8>& info,
|
||||
const std::pair<u64, u64>* motion = nullptr) {
|
||||
std::vector<u8> RewriteMeta(std::vector<u8>&& meta, const std::vector<u8>& info) {
|
||||
Serialization::Archive ar{std::move(meta)};
|
||||
Serialization::Reader reader{ar};
|
||||
u32 meta_version{}, binary_version{};
|
||||
u64 perm_hash{}, perm_idx{}, motion_params{}, motion_positions{};
|
||||
u64 perm_hash{}, perm_idx{};
|
||||
reader.Read(meta_version);
|
||||
reader.Read(binary_version);
|
||||
if (meta_version != Vulkan::PipelineStore::ShaderMetaVersion) {
|
||||
return {};
|
||||
}
|
||||
/* bbport: the on-disk meta layout is exactly the one RegisterShaderMeta() writes and
|
||||
* LoadShaderMeta() reads: meta version, binary version, perm hash, perm index, then
|
||||
* StageSpecialization and Info. This function used to read and write two extra u64s (the
|
||||
* object-motion buffer addresses) here, which shifted the specialization by 16 bytes and made
|
||||
* every imported recipe unreadable. The motion addresses are passed to the importer on the
|
||||
* command line and are not part of the saved meta. */
|
||||
(void)binary_version; // the pack may predate the current compiler; it is re-emitted below
|
||||
reader.Read(perm_hash);
|
||||
reader.Read(perm_idx);
|
||||
reader.Read(motion_params);
|
||||
reader.Read(motion_positions);
|
||||
if (motion) {
|
||||
std::tie(motion_params, motion_positions) = *motion;
|
||||
}
|
||||
Shader::Info old_info{};
|
||||
Shader::StageSpecialization spec{};
|
||||
spec.info = &old_info;
|
||||
@@ -163,8 +164,6 @@ std::vector<u8> RewriteMeta(std::vector<u8>&& meta, const std::vector<u8>& info,
|
||||
writer.Write(Vulkan::PipelineStore::ShaderBinaryVersion);
|
||||
writer.Write(perm_hash);
|
||||
writer.Write(perm_idx);
|
||||
writer.Write(motion_params);
|
||||
writer.Write(motion_positions);
|
||||
spec.Serialize(out);
|
||||
auto bytes = out.TakeOff();
|
||||
bytes.insert(bytes.end(), info.begin(), info.end());
|
||||
@@ -173,6 +172,12 @@ std::vector<u8> RewriteMeta(std::vector<u8>&& meta, const std::vector<u8>& info,
|
||||
|
||||
bool RebuildDirectory(const fs::path& dir, const Shader::Profile& profile, u32& translated,
|
||||
u32& failed) {
|
||||
#ifdef _WIN32
|
||||
void (*old_abrt)(int) = std::signal(SIGABRT, RecipeSignalHandler);
|
||||
void (*old_segv)(int) = std::signal(SIGSEGV, RecipeSignalHandler);
|
||||
void (*old_ill)(int) = std::signal(SIGILL, RecipeSignalHandler);
|
||||
PVOID veh = AddVectoredExceptionHandler(1, RecipeVectoredHandler);
|
||||
#endif
|
||||
struct Record {
|
||||
fs::path path;
|
||||
BbShaderCapture::Input input;
|
||||
@@ -196,11 +201,23 @@ bool RebuildDirectory(const fs::path& dir, const Shader::Profile& profile, u32&
|
||||
std::vector<u8> program_info;
|
||||
for (auto& [perm_idx, record] : perms) {
|
||||
std::optional<Translated> result;
|
||||
#ifdef _WIN32
|
||||
if (setjmp(g_recipe_jmp) == 0) {
|
||||
try {
|
||||
result = Translate(record.input, profile);
|
||||
} catch (const std::exception&) {
|
||||
result.reset();
|
||||
}
|
||||
} else {
|
||||
result.reset();
|
||||
}
|
||||
#else
|
||||
try {
|
||||
result = Translate(record.input, profile);
|
||||
} catch (const std::exception&) {
|
||||
result.reset();
|
||||
}
|
||||
#endif
|
||||
const std::string name = HexName(pgm_hash) + "_" + std::to_string(perm_idx);
|
||||
if (!result) {
|
||||
fs::remove(dir / (name + ".spv"), ec);
|
||||
@@ -232,6 +249,14 @@ bool RebuildDirectory(const fs::path& dir, const Shader::Profile& profile, u32&
|
||||
++translated;
|
||||
}
|
||||
}
|
||||
#ifdef _WIN32
|
||||
std::signal(SIGABRT, old_abrt);
|
||||
std::signal(SIGSEGV, old_segv);
|
||||
std::signal(SIGILL, old_ill);
|
||||
if (veh) {
|
||||
RemoveVectoredExceptionHandler(veh);
|
||||
}
|
||||
#endif
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -465,7 +490,7 @@ bool TranslateRecipe(const RecipeProgram& program, const GameShaders& game,
|
||||
const std::string name = HexName(program.hash) + "_" + std::to_string(perm_idx);
|
||||
std::vector<u8> meta;
|
||||
try {
|
||||
meta = RewriteMeta(std::vector<u8>(perm.meta), program_info, &motion);
|
||||
meta = RewriteMeta(std::vector<u8>(perm.meta), program_info);
|
||||
} catch (const std::exception&) {
|
||||
meta.clear();
|
||||
}
|
||||
@@ -553,9 +578,28 @@ int RebuildCaches() {
|
||||
u32 versions[HeaderWords];
|
||||
std::memcpy(versions, header.data(), sizeof(versions));
|
||||
if (versions[0] != CacheMagic ||
|
||||
versions[1] == Vulkan::PipelineStore::ShaderBinaryVersion ||
|
||||
versions[2] != Vulkan::PipelineStore::ShaderMetaVersion ||
|
||||
versions[3] != Vulkan::PipelineStore::PipelineKeyVersion) {
|
||||
versions[1] == Vulkan::PipelineStore::ShaderBinaryVersion) {
|
||||
continue;
|
||||
}
|
||||
/* bbport: before the versions were unified, the rebuild tool stamped profile.bin with its
|
||||
* own stale copy of the binary version (24) after translating every shader with the
|
||||
* current compiler. When the meta and key versions are current too, the shaders on disk
|
||||
* are current as well and LoadShaderMeta checks each meta on its own, so the header is
|
||||
* corrected here instead of re-translating the whole cache on the next launch. Any other
|
||||
* mismatch keeps the old rebuild path. */
|
||||
constexpr u32 StaleToolBinaryVersion = 24u;
|
||||
if (versions[1] == StaleToolBinaryVersion &&
|
||||
versions[2] == Vulkan::PipelineStore::ShaderMetaVersion &&
|
||||
versions[3] == Vulkan::PipelineStore::PipelineKeyVersion) {
|
||||
versions[1] = Vulkan::PipelineStore::ShaderBinaryVersion;
|
||||
std::memcpy(header.data(), versions, sizeof(versions));
|
||||
if (WriteFile(dir / "profile.bin", header)) {
|
||||
std::printf("Shader cache %s: version header updated to %u without "
|
||||
"re-translating\n",
|
||||
dir.filename().string().c_str(),
|
||||
Vulkan::PipelineStore::ShaderBinaryVersion);
|
||||
std::fflush(stdout);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Shader::Profile profile{};
|
||||
@@ -576,6 +620,9 @@ int RebuildCaches() {
|
||||
if (!rebuilt_ok) {
|
||||
std::printf("Shader cache %s: rebuilding failed, it is compiled again in the game\n",
|
||||
dir.filename().string().c_str());
|
||||
versions[1] = Vulkan::PipelineStore::ShaderBinaryVersion;
|
||||
std::memcpy(header.data(), versions, sizeof(versions));
|
||||
WriteFile(dir / "profile.bin", header);
|
||||
continue;
|
||||
}
|
||||
versions[1] = Vulkan::PipelineStore::ShaderBinaryVersion;
|
||||
@@ -719,6 +766,24 @@ int MergeMetas(const char* cache_dir, const char* pack, const char* game_dir, co
|
||||
return 1; // Handled as fallback to standard pack import
|
||||
}
|
||||
|
||||
/// True when every piece of guest code a recipe needs is present in this game build (program and
|
||||
/// all referenced shaders). TranslateRecipe() fails immediately when something is missing, and the
|
||||
/// answer cannot change until either the pack or the game build does.
|
||||
bool RecipeCodeAvailable(const RecipeProgram& program, const GameShaders& game) {
|
||||
if (!game.Code(program.hash)) {
|
||||
return false;
|
||||
}
|
||||
for (const auto& perm : program.perms) {
|
||||
for (const auto& ref : perm.refs) {
|
||||
const auto* ref_code = game.Code(ref.hash);
|
||||
if (!ref_code || ref.size > ref_code->size() * sizeof(u32)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
int ImportRecipes(const char* cache_dir, const char* pack, const char* game_dir,
|
||||
u64 motion_params, u64 motion_positions) {
|
||||
const auto start = std::chrono::steady_clock::now();
|
||||
@@ -831,6 +896,7 @@ int ImportRecipes(const char* cache_dir, const char* pack, const char* game_dir,
|
||||
TranslateRecipe(*program, game, profile, motion, staging);
|
||||
} else {
|
||||
std::ofstream{dir / "import.failed", std::ios::app} << HexName(hash) << '\n';
|
||||
failed.insert(hash);
|
||||
}
|
||||
}
|
||||
std::signal(SIGABRT, old_abrt);
|
||||
@@ -870,11 +936,13 @@ int ImportRecipes(const char* cache_dir, const char* pack, const char* game_dir,
|
||||
break;
|
||||
}
|
||||
std::ofstream{dir / "import.failed", std::ios::app} << HexName(todo[index]->hash) << '\n';
|
||||
failed.insert(todo[index]->hash);
|
||||
next = index + 1;
|
||||
}
|
||||
#endif
|
||||
fs::remove(staging / "progress", ec);
|
||||
std::unordered_set<u64> perm_hashes;
|
||||
std::vector<u64> silently_failed;
|
||||
u32 imported = 0, perms = 0;
|
||||
for (const auto* program : todo) {
|
||||
if (fs::exists(staging / (HexName(program->hash) + "_0.spv"), ec)) {
|
||||
@@ -883,6 +951,19 @@ int ImportRecipes(const char* cache_dir, const char* pack, const char* game_dir,
|
||||
for (size_t perm_idx = 0; perm_idx < program->perms.size(); ++perm_idx) {
|
||||
perm_hashes.insert(HashCombine(program->hash, u64(perm_idx)));
|
||||
}
|
||||
} else if (!failed.contains(program->hash) && !RecipeCodeAvailable(*program, game)) {
|
||||
/* bbport: TranslateRecipe() returns false for a program whose code is not in the
|
||||
* running game build (and for a few other deterministic failures) without recording
|
||||
* it, so the below-normal-priority import child retried the same hundreds of recipes
|
||||
* on every single launch: 6+ seconds of CPU contention against the game. Record only
|
||||
* the provably impossible ones; transient failures (I/O, translation) stay retryable. */
|
||||
silently_failed.push_back(program->hash);
|
||||
}
|
||||
}
|
||||
if (!silently_failed.empty()) {
|
||||
std::ofstream list{dir / "import.failed", std::ios::app};
|
||||
for (const u64 hash : silently_failed) {
|
||||
list << HexName(hash) << '\n';
|
||||
}
|
||||
}
|
||||
u32 pipelines = 0;
|
||||
|
||||
@@ -7,14 +7,24 @@
|
||||
namespace Vulkan {
|
||||
|
||||
vk::ShaderModule CompileSPV(std::span<const u32> code, vk::Device device) {
|
||||
if (code.empty()) {
|
||||
LOG_CRITICAL(Render_Vulkan, "CompileSPV: empty SPIR-V module");
|
||||
return {};
|
||||
}
|
||||
const vk::ShaderModuleCreateInfo shader_info = {
|
||||
.codeSize = code.size() * sizeof(u32),
|
||||
.pCode = code.data(),
|
||||
};
|
||||
|
||||
auto [module_result, module] = device.createShaderModule(shader_info);
|
||||
ASSERT_MSG(module_result == vk::Result::eSuccess, "Failed to compile SPIR-V shader: {}",
|
||||
vk::to_string(module_result));
|
||||
/* bbport: a shader the driver rejects used to abort the process (exit code 23). The callers
|
||||
* that build pipelines handle a null module (the pipeline then fails to create and is
|
||||
* skipped); a host pass with static SPIR-V only gets here on a broken device. */
|
||||
if (module_result != vk::Result::eSuccess) {
|
||||
LOG_CRITICAL(Render_Vulkan, "Failed to compile SPIR-V shader: {}",
|
||||
vk::to_string(module_result));
|
||||
return {};
|
||||
}
|
||||
return module;
|
||||
}
|
||||
|
||||
|
||||
@@ -1354,12 +1354,19 @@ void TemporalUpscaler::OnColorTarget(VideoCore::ImageId color) {
|
||||
void TemporalUpscaler::OnDraw(u64 vs_hash, VideoCore::ImageId color,
|
||||
VideoCore::ImageId depth, bool native_viewport) {
|
||||
if (!Scaled() && vs_hash == ui_trigger_vs) done_this_frame = true;
|
||||
if (!Scaled() || !color) {
|
||||
if (!Scaled()) {
|
||||
return;
|
||||
}
|
||||
if (ui_phase) {
|
||||
// A UI movie can start without stencil and enable it for later text/masks.
|
||||
if (color == ui_color && depth && depth != ui_depth) {
|
||||
// A UI movie can start without stencil and enable it for later text/masks. Masks are
|
||||
// drawn with color writes off: a depth of the UI target's size is the UI's then.
|
||||
const auto ui_sized = [&] {
|
||||
const auto& d = texture_cache.GetImage(depth).info.size;
|
||||
const auto& c = texture_cache.GetImage(ui_color).info.size;
|
||||
return d.width + 8 > c.width && c.width + 8 > d.width &&
|
||||
d.height + 8 > c.height && c.height + 8 > d.height;
|
||||
};
|
||||
if (depth && depth != ui_depth && (color ? color == ui_color : ui_sized())) {
|
||||
EnsureUiResources(ui_width, ui_height, ui_format,
|
||||
texture_cache.GetImage(depth).info.pixel_format);
|
||||
PrepareUiDepth(depth);
|
||||
@@ -1367,6 +1374,9 @@ void TemporalUpscaler::OnDraw(u64 vs_hash, VideoCore::ImageId color,
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!color) {
|
||||
return;
|
||||
}
|
||||
const auto& image = texture_cache.GetImage(color);
|
||||
const bool movie = vs_hash == ui_trigger_vs || UiComposition::MovieShader(vs_hash) ||
|
||||
(scaled_session && (render_width != 1920 || render_height != 1080) && native_viewport);
|
||||
|
||||
@@ -432,4 +432,55 @@ void BlitHelper::CreateMsCopyPipeline(const MsPipelineKey& key) {
|
||||
ms_image_copy_pl.emplace_back(key, std::move(pipeline));
|
||||
}
|
||||
|
||||
u64 BlitHelper::BuildUploadCopies(const Image& image, u32 num_mips, u32 num_layers,
|
||||
boost::container::small_vector<vk::BufferImageCopy, 14>& out_copies) {
|
||||
const u32 skip = image.info.skip_levels;
|
||||
const u64 skipped_offset = (skip > 0 && image.guest_info.guest_address != 0)
|
||||
? image.guest_info.mips_layout[skip].offset
|
||||
: 0;
|
||||
|
||||
for (u32 m = 0; m < num_mips; m++) {
|
||||
const u32 width = std::max(image.info.size.width >> m, 1u);
|
||||
const u32 height = std::max(image.info.size.height >> m, 1u);
|
||||
const u32 depth =
|
||||
image.info.props.is_volume ? std::max(image.info.size.depth >> m, 1u) : 1u;
|
||||
const auto [mip_size, mip_pitch, mip_height, mip_offset] = image.info.mips_layout[m];
|
||||
|
||||
const u32 extent_width = mip_pitch ? std::min(mip_pitch, width) : width;
|
||||
const u32 extent_height = mip_height ? std::min(mip_height, height) : height;
|
||||
|
||||
const u64 linear_mip_offset = (image.info.props.is_tiled && skipped_offset != 0)
|
||||
? (mip_offset >= skipped_offset ? mip_offset - skipped_offset : 0)
|
||||
: mip_offset;
|
||||
|
||||
out_copies.push_back({
|
||||
.bufferOffset = linear_mip_offset,
|
||||
.bufferRowLength = mip_pitch,
|
||||
.bufferImageHeight = mip_height,
|
||||
.imageSubresource{
|
||||
.aspectMask = image.aspect_mask & ~vk::ImageAspectFlagBits::eStencil,
|
||||
.mipLevel = m,
|
||||
.baseArrayLayer = 0,
|
||||
.layerCount = num_layers,
|
||||
},
|
||||
.imageOffset = {0, 0, 0},
|
||||
.imageExtent = {extent_width, extent_height, depth},
|
||||
});
|
||||
}
|
||||
|
||||
if (skip > 0) {
|
||||
static std::atomic<u32> skipped_log_count{0};
|
||||
const u32 total = ++skipped_log_count;
|
||||
if (total <= 20 || total % 50 == 0) {
|
||||
std::printf("[Texture Quality] Skipped %u top mip(s) during upload: guest %ux%u (%u mips, skipped %llu B) -> Vulkan %ux%u (%u mips) [total uploads: %u]\n",
|
||||
skip, image.guest_info.size.width, image.guest_info.size.height,
|
||||
image.guest_info.resources.levels, static_cast<unsigned long long>(skipped_offset),
|
||||
image.info.size.width, image.info.size.height, image.info.resources.levels, total);
|
||||
std::fflush(stdout);
|
||||
}
|
||||
}
|
||||
|
||||
return skipped_offset;
|
||||
}
|
||||
|
||||
} // namespace VideoCore
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <boost/container/small_vector.hpp>
|
||||
#include "common/types.h"
|
||||
#include "video_core/renderer_vulkan/vk_common.h"
|
||||
|
||||
@@ -30,6 +31,8 @@ public:
|
||||
|
||||
void CopyBetweenMsImages(u32 width, u32 height, u32 num_samples, vk::Format pixel_format,
|
||||
bool src_msaa, vk::Image source, vk::Image dest);
|
||||
u64 BuildUploadCopies(const Image& image, u32 num_mips, u32 num_layers,
|
||||
boost::container::small_vector<vk::BufferImageCopy, 14>& out_copies);
|
||||
|
||||
private:
|
||||
void CreateShaders();
|
||||
|
||||
@@ -139,7 +139,7 @@ void UniqueImage::Create(const vk::ImageCreateInfo& image_ci) {
|
||||
Image::Image(const Vulkan::Instance& instance, Vulkan::Runtime& runtime_,
|
||||
Common::SlotVector<ImageView>& slot_image_views_, const ImageInfo& info_)
|
||||
: guest_begin{info_.guest_address}, guest_end{info_.guest_address + info_.guest_size},
|
||||
info{info_}, runtime{&runtime_}, slot_image_views{&slot_image_views_} {
|
||||
info{info_}, guest_info{info_}, runtime{&runtime_}, slot_image_views{&slot_image_views_} {
|
||||
BbStats::Timer timer{BbStats::t_image_create};
|
||||
if (info.pixel_format == vk::Format::eUndefined) {
|
||||
return;
|
||||
|
||||
@@ -191,6 +191,7 @@ public:
|
||||
u64 image_uid{};
|
||||
|
||||
ImageInfo info;
|
||||
ImageInfo guest_info;
|
||||
Vulkan::Runtime* runtime;
|
||||
Common::SlotVector<ImageView>* slot_image_views;
|
||||
vk::ImageAspectFlags aspect_mask = vk::ImageAspectFlagBits::eColor;
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#include "shader_recompiler/resource.h"
|
||||
#include "video_core/renderer_vulkan/liverpool_to_vk.h"
|
||||
#include "video_core/texture_cache/host_compatibility.h"
|
||||
#include "shim/bbport_settings.h"
|
||||
#include "video_core/texture_cache/image_info.h"
|
||||
#include "video_core/texture_cache/tile.h"
|
||||
|
||||
@@ -315,4 +316,58 @@ s32 ImageInfo::SliceOf(const ImageInfo& info, s32 mip) const {
|
||||
return addr_diff / guest_size;
|
||||
}
|
||||
|
||||
ImageInfo ImageInfo::Reduced(u32 skip) const {
|
||||
ImageInfo out = *this;
|
||||
out.size.width = std::max(size.width >> skip, 1u);
|
||||
out.size.height = std::max(size.height >> skip, 1u);
|
||||
out.pitch = std::max(pitch >> skip, 1u);
|
||||
out.resources.levels = resources.levels - skip;
|
||||
for (u32 level = 0; level < out.resources.levels; ++level) {
|
||||
out.mips_layout[level] = mips_layout[level + skip];
|
||||
}
|
||||
out.skip_levels = static_cast<u8>(skip);
|
||||
return out;
|
||||
}
|
||||
|
||||
u32 SkipLevelsFor(const ImageInfo& info, BindingType binding_type, bool is_storage,
|
||||
u64 vram_budget, bool is_gpu_modified) {
|
||||
static const int forced = [] {
|
||||
const char* env = std::getenv("BB_TEXTURE_QUALITY");
|
||||
if (!env || !*env) {
|
||||
return -1;
|
||||
}
|
||||
for (int q = 0; q < BbSettings::TqCount; ++q) {
|
||||
if (std::strcmp(env, BbSettings::TextureQualityName(q)) == 0) {
|
||||
return q;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}();
|
||||
|
||||
int quality = forced >= 0 ? forced : BbSettings::Get().texture_quality.load(std::memory_order_relaxed);
|
||||
if (quality == BbSettings::TqAuto) {
|
||||
quality = vram_budget <= (3ull << 30) ? BbSettings::TqHalf : BbSettings::TqFull;
|
||||
}
|
||||
|
||||
const u32 wanted = quality == BbSettings::TqQuarter ? 2 : quality == BbSettings::TqHalf ? 1 : 0;
|
||||
if (wanted == 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Only sampled, block-compressed, mipmapped 2D textures (materials).
|
||||
// Not render targets, storage, depth, volume, or lookup tables.
|
||||
if (binding_type != BindingType::Texture || is_storage ||
|
||||
!info.props.is_block || info.props.is_depth || info.props.is_volume ||
|
||||
info.num_samples != 1 || info.resources.levels < 3 || is_gpu_modified) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
u32 skip = 0;
|
||||
while (skip < wanted && info.resources.levels - skip >= 3 &&
|
||||
std::min(info.size.width, info.size.height) >> skip >= 512) {
|
||||
++skip;
|
||||
}
|
||||
return skip;
|
||||
}
|
||||
|
||||
} // namespace VideoCore
|
||||
@@ -89,6 +89,15 @@ struct ImageInfo {
|
||||
|
||||
VAddr stencil_addr{};
|
||||
u32 stencil_size{};
|
||||
u8 skip_levels{};
|
||||
|
||||
[[nodiscard]] ImageInfo Reduced(u32 skip) const;
|
||||
};
|
||||
|
||||
/// Computes how many mip levels to skip for texture quality downscaling.
|
||||
/// Targets 2D block-compressed textures (BC1-BC7) with >= 3 mip levels and dimensions >= 512x512.
|
||||
/// Ignores render targets, depth buffers, storage images, and lookup tables.
|
||||
u32 SkipLevelsFor(const ImageInfo& info, BindingType binding_type, bool is_storage,
|
||||
u64 vram_budget, bool is_gpu_modified);
|
||||
|
||||
} // namespace VideoCore
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
#include "bbport_button_prompts.h"
|
||||
#include "bbport_toggles.h"
|
||||
#include "shim/bbport_settings.h"
|
||||
#include "common/assert.h"
|
||||
#include "common/debug.h"
|
||||
#include "common/div_ceil.h"
|
||||
@@ -75,10 +76,46 @@ TextureCache::TextureCache(const Vulkan::Instance& instance_, Vulkan::Scheduler&
|
||||
critical_gc_memory = DEFAULT_CRITICAL_GC_MEMORY;
|
||||
trigger_gc_memory = 0;
|
||||
}
|
||||
|
||||
const u64 total_budget = instance.GetTotalMemoryBudget();
|
||||
const double budget_gb = static_cast<double>(total_budget) / (1024.0 * 1024.0 * 1024.0);
|
||||
int quality = BbSettings::Get().texture_quality.load(std::memory_order_relaxed);
|
||||
if (const char* env = std::getenv("BB_TEXTURE_QUALITY")) {
|
||||
for (int q = 0; q < BbSettings::TqCount; ++q) {
|
||||
if (std::strcmp(env, BbSettings::TextureQualityName(q)) == 0) {
|
||||
quality = q;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const char* mode_str = BbSettings::TextureQualityName(quality);
|
||||
int effective = quality;
|
||||
if (quality == BbSettings::TqAuto) {
|
||||
effective = total_budget <= (3ull << 30) ? BbSettings::TqHalf : BbSettings::TqFull;
|
||||
}
|
||||
const char* eff_str = BbSettings::TextureQualityName(effective);
|
||||
std::printf("[Texture Quality] Initialized: setting='%s', effective='%s' (Detected VRAM: %.2f GB)\n",
|
||||
mode_str, eff_str, budget_gb);
|
||||
std::fflush(stdout);
|
||||
}
|
||||
|
||||
TextureCache::~TextureCache() = default;
|
||||
|
||||
void TextureCache::AdjustView(const Image& image, ImageViewInfo& view) {
|
||||
const u32 skip = image.info.skip_levels;
|
||||
if (skip == 0) {
|
||||
return;
|
||||
}
|
||||
const u32 first = view.range.base.level;
|
||||
const u32 last = first + std::max(view.range.extent.levels, 1u) - 1;
|
||||
const u32 levels = image.info.resources.levels;
|
||||
const u32 new_first = std::min(first > skip ? first - skip : 0u, levels - 1);
|
||||
const u32 new_last = std::min(last > skip ? last - skip : 0u, levels - 1);
|
||||
view.range.base.level = new_first;
|
||||
view.range.extent.levels = std::max(new_last, new_first) - new_first + 1;
|
||||
view.min_lod = view.min_lod > skip * 256 ? view.min_lod - skip * 256 : 0;
|
||||
}
|
||||
|
||||
void TextureCache::ProcessDownloadImages() {
|
||||
std::unique_lock lk{download_images_mutex};
|
||||
if (download_images.empty()) {
|
||||
@@ -824,6 +861,7 @@ ImageId TextureCache::FindImage(ImageDesc& desc, bool exact_fmt) {
|
||||
if (cached.view_slice > 0) {
|
||||
desc.view_info.range.base.layer = cached.view_slice;
|
||||
}
|
||||
AdjustView(image, desc.view_info);
|
||||
return cached.image_id;
|
||||
}
|
||||
|
||||
@@ -831,6 +869,31 @@ ImageId TextureCache::FindImage(ImageDesc& desc, bool exact_fmt) {
|
||||
ForEachImageInRegion(info.guest_address, info.guest_size,
|
||||
[&](ImageId image_id, Image& image) { image_ids.push_back(image_id); });
|
||||
|
||||
// A texture without its largest levels (texture_quality) serves only the sampled binding of
|
||||
// that very texture: bound otherwise, or met by another image over it, it goes (made again
|
||||
// whole) and its address keeps its textures whole from then on.
|
||||
for (auto it = image_ids.begin(); it != image_ids.end();) {
|
||||
const Image& cached_img = slot_images[*it];
|
||||
if (cached_img.info.skip_levels == 0) {
|
||||
++it;
|
||||
continue;
|
||||
}
|
||||
const ImageInfo& whole = cached_img.guest_info;
|
||||
const bool same = desc.type == BindingType::Texture &&
|
||||
whole.guest_address == info.guest_address &&
|
||||
whole.guest_size == info.guest_size && whole.size == info.size &&
|
||||
whole.type == info.type && whole.resources == info.resources &&
|
||||
IsVulkanFormatCompatible(whole.pixel_format, info.pixel_format);
|
||||
if (same) {
|
||||
++it;
|
||||
continue;
|
||||
}
|
||||
whole_textures.insert(whole.guest_address);
|
||||
const ImageId id = *it;
|
||||
it = image_ids.erase(it);
|
||||
FreeImage(id);
|
||||
}
|
||||
|
||||
ImageId image_id{};
|
||||
|
||||
// Check for a perfect match first
|
||||
@@ -839,20 +902,21 @@ ImageId TextureCache::FindImage(ImageDesc& desc, bool exact_fmt) {
|
||||
continue;
|
||||
}
|
||||
auto& cache_image = slot_images[cache_id];
|
||||
if (cache_image.info.guest_address != info.guest_address) {
|
||||
const auto& match_info = cache_image.guest_info.guest_address != 0 ? cache_image.guest_info : cache_image.info;
|
||||
if (match_info.guest_address != info.guest_address) {
|
||||
continue;
|
||||
}
|
||||
if (cache_image.info.guest_size != info.guest_size) {
|
||||
if (match_info.guest_size != info.guest_size) {
|
||||
continue;
|
||||
}
|
||||
if (cache_image.info.size != info.size) {
|
||||
if (match_info.size != info.size) {
|
||||
continue;
|
||||
}
|
||||
if (!IsVulkanFormatCompatible(cache_image.info.pixel_format, info.pixel_format) ||
|
||||
(cache_image.info.type != info.type && info.size != Extent3D{1, 1, 1})) {
|
||||
if (!IsVulkanFormatCompatible(match_info.pixel_format, info.pixel_format) ||
|
||||
(match_info.type != info.type && info.size != Extent3D{1, 1, 1})) {
|
||||
continue;
|
||||
}
|
||||
if (exact_fmt && info.pixel_format != cache_image.info.pixel_format) {
|
||||
if (exact_fmt && info.pixel_format != match_info.pixel_format) {
|
||||
continue;
|
||||
}
|
||||
image_id = cache_id;
|
||||
@@ -882,10 +946,11 @@ ImageId TextureCache::FindImage(ImageDesc& desc, bool exact_fmt) {
|
||||
|
||||
if (image_id) {
|
||||
Image& image_resolved = slot_images[image_id];
|
||||
if (exact_fmt && info.pixel_format != image_resolved.info.pixel_format) {
|
||||
const auto& check_info = image_resolved.guest_info.guest_address != 0 ? image_resolved.guest_info : image_resolved.info;
|
||||
if (exact_fmt && info.pixel_format != check_info.pixel_format) {
|
||||
// Cannot reuse this image as we need the exact requested format.
|
||||
image_id = {};
|
||||
} else if (!image_resolved.info.resources.Contains(info.resources)) {
|
||||
} else if (!check_info.resources.Contains(info.resources)) {
|
||||
// The image was clearly picked up wrong.
|
||||
FreeImage(image_id);
|
||||
image_id = {};
|
||||
@@ -894,7 +959,18 @@ ImageId TextureCache::FindImage(ImageDesc& desc, bool exact_fmt) {
|
||||
}
|
||||
// Create and register a new image
|
||||
if (!image_id) {
|
||||
image_id = slot_images.insert(instance, runtime, slot_image_views, info);
|
||||
const u64 budget = instance.GetTotalMemoryBudget();
|
||||
const bool is_gpu_mod = buffer_cache.IsRegionGpuModified(info.guest_address, info.guest_size);
|
||||
const bool keep_whole = whole_textures.contains(info.guest_address);
|
||||
const u32 skip = keep_whole ? 0 : SkipLevelsFor(info, desc.type, desc.view_info.is_storage, budget, is_gpu_mod);
|
||||
|
||||
if (skip > 0) {
|
||||
image_id = slot_images.insert(instance, runtime, slot_image_views, info.Reduced(skip));
|
||||
slot_images[image_id].guest_info = info;
|
||||
} else {
|
||||
image_id = slot_images.insert(instance, runtime, slot_image_views, info);
|
||||
slot_images[image_id].guest_info = info;
|
||||
}
|
||||
RegisterImage(image_id);
|
||||
}
|
||||
|
||||
@@ -909,6 +985,7 @@ ImageId TextureCache::FindImage(ImageDesc& desc, bool exact_fmt) {
|
||||
if (view_slice > 0) {
|
||||
desc.view_info.range.base.layer = view_slice;
|
||||
}
|
||||
AdjustView(image, desc.view_info);
|
||||
|
||||
cached = FindImageCacheEntry{
|
||||
.address = info.guest_address,
|
||||
@@ -1103,36 +1180,17 @@ void TextureCache::RefreshImage(Image& image) {
|
||||
|
||||
BbStats::image_upload_bytes.fetch_add(image.info.guest_size, std::memory_order_relaxed);
|
||||
boost::container::small_vector<vk::BufferImageCopy, 14> image_copies;
|
||||
for (u32 m = 0; m < num_mips; m++) {
|
||||
const u32 width = std::max(image.info.size.width >> m, 1u);
|
||||
const u32 height = std::max(image.info.size.height >> m, 1u);
|
||||
const u32 depth =
|
||||
image.info.props.is_volume ? std::max(image.info.size.depth >> m, 1u) : 1u;
|
||||
const auto [mip_size, mip_pitch, mip_height, mip_offset] = image.info.mips_layout[m];
|
||||
blit_helper.BuildUploadCopies(image, num_mips, num_layers, image_copies);
|
||||
|
||||
// Protect GPU modified resources from accidental CPU reuploads.
|
||||
// Protect GPU modified resources from accidental CPU reuploads.
|
||||
for (u32 m = 0; m < num_mips; m++) {
|
||||
const auto [mip_size, mip_pitch, mip_height, mip_offset] = image.info.mips_layout[m];
|
||||
const u8* mip_addr = std::bit_cast<u8*>(image.info.guest_address) + mip_offset;
|
||||
const u64 mip_hash = XXH3_64bits(mip_addr, mip_size);
|
||||
if (is_gpu_modified && !is_gpu_dirty && image.mip_hashes[m] == mip_hash) {
|
||||
continue;
|
||||
}
|
||||
image.mip_hashes[m] = mip_hash;
|
||||
|
||||
const u32 extent_width = mip_pitch ? std::min(mip_pitch, width) : width;
|
||||
const u32 extent_height = mip_height ? std::min(mip_height, height) : height;
|
||||
image_copies.push_back({
|
||||
.bufferOffset = mip_offset,
|
||||
.bufferRowLength = mip_pitch,
|
||||
.bufferImageHeight = mip_height,
|
||||
.imageSubresource{
|
||||
.aspectMask = image.aspect_mask & ~vk::ImageAspectFlagBits::eStencil,
|
||||
.mipLevel = m,
|
||||
.baseArrayLayer = 0,
|
||||
.layerCount = num_layers,
|
||||
},
|
||||
.imageOffset = {0, 0, 0},
|
||||
.imageExtent = {extent_width, extent_height, depth},
|
||||
});
|
||||
}
|
||||
|
||||
if (image_copies.empty()) {
|
||||
@@ -1142,9 +1200,10 @@ void TextureCache::RefreshImage(Image& image) {
|
||||
|
||||
scheduler.EndRendering();
|
||||
|
||||
const ImageInfo& upload_info = image.guest_info.guest_address != 0 ? image.guest_info : image.info;
|
||||
const auto [in_buffer, in_offset] =
|
||||
buffer_cache.ObtainBufferForImage(image.info.guest_address, image.info.guest_size);
|
||||
const auto [buffer, offset] = tile_manager.DetileImage(in_buffer, in_offset, image.info);
|
||||
buffer_cache.ObtainBufferForImage(upload_info.guest_address, upload_info.guest_size);
|
||||
const auto [buffer, offset] = tile_manager.DetileImage(in_buffer, in_offset, upload_info, image.info.skip_levels);
|
||||
for (auto& copy : image_copies) {
|
||||
copy.bufferOffset += offset;
|
||||
}
|
||||
|
||||
@@ -55,13 +55,7 @@ class TextureCache {
|
||||
using PageTable = MultiLevelPageTable<Traits>;
|
||||
|
||||
public:
|
||||
enum class BindingType : u32 {
|
||||
Texture,
|
||||
Storage,
|
||||
RenderTarget,
|
||||
DepthTarget,
|
||||
VideoOut,
|
||||
};
|
||||
using BindingType = VideoCore::BindingType;
|
||||
|
||||
struct ImageDesc {
|
||||
ImageInfo info;
|
||||
@@ -122,6 +116,9 @@ public:
|
||||
/// Retrieves the image handle of the image with the provided attributes.
|
||||
[[nodiscard]] ImageId FindImage(ImageDesc& desc, bool exact_fmt = false);
|
||||
|
||||
/// Adjusts view mip levels when the image has reduced texture quality (skipped top mips).
|
||||
void AdjustView(const Image& image, ImageViewInfo& view);
|
||||
|
||||
/// Retrieves image whose address matches provided
|
||||
[[nodiscard]] ImageId FindImageFromRange(VAddr address, size_t size, bool ensure_valid = true);
|
||||
|
||||
@@ -418,6 +415,7 @@ private:
|
||||
Common::SlotVector<ImageView> slot_image_views;
|
||||
tsl::robin_map<u64, Sampler> samplers;
|
||||
std::unordered_set<ImageId> download_images;
|
||||
std::unordered_set<VAddr> whole_textures;
|
||||
u64 total_used_memory = 0;
|
||||
u64 gc_evictions = 0, gc_downloads = 0; ///< bbport: pressure report
|
||||
std::chrono::steady_clock::time_point gc_report_time{};
|
||||
|
||||
@@ -228,7 +228,8 @@ void TileManager::RecordTilingDispatch(vk::Pipeline pipeline, const vk::Descript
|
||||
}
|
||||
|
||||
std::pair<const Buffer*, u64> TileManager::DetileImage(const VideoCore::Buffer* in_buffer,
|
||||
u64 in_offset, const ImageInfo& info) {
|
||||
u64 in_offset, const ImageInfo& info,
|
||||
u32 first_level) {
|
||||
if (!info.props.is_tiled) {
|
||||
return {in_buffer, in_offset};
|
||||
}
|
||||
@@ -237,7 +238,13 @@ std::pair<const Buffer*, u64> TileManager::DetileImage(const VideoCore::Buffer*
|
||||
if (bpp == 0 || info.guest_size == 0) {
|
||||
return {in_buffer, in_offset};
|
||||
}
|
||||
const auto dim_x = static_cast<u32>((info.guest_size / bpp) / 64);
|
||||
first_level = std::min(first_level, info.resources.levels - 1);
|
||||
const u64 skipped = first_level != 0 ? info.mips_layout[first_level].offset : 0;
|
||||
const u64 bytes = info.guest_size > skipped ? info.guest_size - skipped : 0;
|
||||
if (bytes == 0) {
|
||||
return {in_buffer, in_offset};
|
||||
}
|
||||
const auto dim_x = static_cast<u32>((bytes / bpp) / 64);
|
||||
if (dim_x == 0) {
|
||||
return {in_buffer, in_offset};
|
||||
}
|
||||
@@ -245,10 +252,10 @@ std::pair<const Buffer*, u64> TileManager::DetileImage(const VideoCore::Buffer*
|
||||
TilingInfo params{};
|
||||
params.bank_swizzle = info.bank_swizzle;
|
||||
params.num_slices = info.props.is_volume ? info.size.depth : info.resources.layers;
|
||||
params.num_mips = info.resources.levels;
|
||||
params.num_mips = info.resources.levels - first_level;
|
||||
for (u32 mip = 0; mip < params.num_mips; ++mip) {
|
||||
auto& mip_info = params.mips[mip];
|
||||
mip_info = info.mips_layout[mip];
|
||||
mip_info = info.mips_layout[mip + first_level];
|
||||
if (info.props.is_block) {
|
||||
mip_info.pitch = std::max((mip_info.pitch + 3) / 4, 1U);
|
||||
mip_info.height = std::max((mip_info.height + 3) / 4, 1U);
|
||||
@@ -261,7 +268,7 @@ std::pair<const Buffer*, u64> TileManager::DetileImage(const VideoCore::Buffer*
|
||||
.range = sizeof(params),
|
||||
};
|
||||
|
||||
const auto staging = runtime.GetStagingPool().Request(info.guest_size, MemoryType::DeviceLocal);
|
||||
const auto staging = runtime.GetStagingPool().Request(bytes, MemoryType::DeviceLocal);
|
||||
|
||||
scheduler.EndRendering();
|
||||
runtime.FlushBarriers();
|
||||
@@ -274,7 +281,7 @@ std::pair<const Buffer*, u64> TileManager::DetileImage(const VideoCore::Buffer*
|
||||
const vk::DescriptorBufferInfo linear_buffer_info{
|
||||
.buffer = staging.buffer->Handle(),
|
||||
.offset = staging.offset,
|
||||
.range = info.guest_size,
|
||||
.range = bytes,
|
||||
};
|
||||
// bbport: recorded with copies of the descriptor infos (threaded recording).
|
||||
RecordTilingDispatch(GetTilingPipeline(info, false), tiled_buffer_info, linear_buffer_info,
|
||||
|
||||
@@ -50,7 +50,7 @@ public:
|
||||
const VideoCore::Buffer* out_buffer, u64 out_offset);
|
||||
|
||||
std::pair<const Buffer*, u64> DetileImage(const VideoCore::Buffer* in_buffer, u64 in_offset,
|
||||
const ImageInfo& info);
|
||||
const ImageInfo& info, u32 first_level = 0);
|
||||
|
||||
private:
|
||||
void RecordTilingDispatch(vk::Pipeline pipeline, const vk::DescriptorBufferInfo& tiled,
|
||||
|
||||
@@ -103,4 +103,12 @@ struct BufferCopy {
|
||||
std::size_t size;
|
||||
};
|
||||
|
||||
enum class BindingType : u32 {
|
||||
Texture,
|
||||
Storage,
|
||||
RenderTarget,
|
||||
DepthTarget,
|
||||
VideoOut,
|
||||
};
|
||||
|
||||
} // namespace VideoCore
|
||||
+527
-513
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,14 @@
|
||||
// bbport: flags that are only valid while a guest access violation is being handled.
|
||||
// src/probe.c sets them around bbgpu_handle_fault(); the page tracker reads them.
|
||||
#pragma once
|
||||
|
||||
namespace BbFaultHint {
|
||||
|
||||
/// The faulting guest instruction is inside a PS4 routine that keeps locals in the System V red
|
||||
/// zone (the particle, blood-splatter and cloth vertex generators). Windows x64 writes its
|
||||
/// exception frame below RSP on every dispatch, which overwrites those locals, so the routine
|
||||
/// must not take another write-tracking fault on the same page. The page is kept writable and
|
||||
/// CPU-modified for the rest of the session instead.
|
||||
inline thread_local bool redzone = false;
|
||||
|
||||
} // namespace BbFaultHint
|
||||
@@ -195,6 +195,12 @@ void Set(Values& v, const std::string& key, const std::string& value) {
|
||||
v.prep_workers = value == "auto" ? -1 : std::clamp(i, 0, 16);
|
||||
} else if (key == "async_pipelines") {
|
||||
v.async_pipelines = value == "auto" ? -1 : std::clamp(i, 0, 16);
|
||||
} else if (key == "texture_quality") {
|
||||
for (int q = 0; q < TqCount; ++q) {
|
||||
if (value == TextureQualityName(q)) v.texture_quality = q;
|
||||
}
|
||||
} else if (key == "save_auto_backup") {
|
||||
v.save_auto_backup = i != 0;
|
||||
} else if (key == "live_resolution") {
|
||||
v.live_resolution = value == "auto" ? -1 : std::clamp(i, 0, 1);
|
||||
} else if (key == "language") {
|
||||
@@ -296,6 +302,8 @@ void Load() {
|
||||
{"BB_HUD_OPACITY", "hud_opacity"}, {"BB_HUD_SCALE", "hud_scale"},
|
||||
{"BB_PREP_WORKERS", "prep_workers"},
|
||||
{"BB_ASYNC_PIPELINES", "async_pipelines"},
|
||||
{"BB_TEXTURE_QUALITY", "texture_quality"},
|
||||
{"BB_SAVE_AUTO_BACKUP", "save_auto_backup"},
|
||||
{"BB_FPS", "target_fps"}, {"BB_LIVE_FPS", "live_fps"},
|
||||
{"BB_TARGET_FPS", "target_fps"}, {"BB_MAX_DT_HZ", "max_dt_hz"},
|
||||
{"BB_LANG", "language"},
|
||||
@@ -314,6 +322,7 @@ void Load() {
|
||||
v.startup_model_lod = v.model_lod;
|
||||
v.startup_prep_workers = v.prep_workers;
|
||||
v.startup_async_pipelines = v.async_pipelines;
|
||||
v.startup_texture_quality = v.texture_quality;
|
||||
v.startup_output_res = v.output_res;
|
||||
v.startup_live_resolution = v.live_resolution;
|
||||
}
|
||||
@@ -449,6 +458,8 @@ void Save() {
|
||||
} else {
|
||||
std::fprintf(file, "async_pipelines=%d\n", v.async_pipelines.load());
|
||||
}
|
||||
std::fprintf(file, "texture_quality=%s\n", TextureQualityName(v.texture_quality.load()));
|
||||
std::fprintf(file, "save_auto_backup=%d\n", v.save_auto_backup.load() ? 1 : 0);
|
||||
// Read by run.sh at start.
|
||||
std::fprintf(file, "live_resolution=%s\n", v.live_resolution < 0 ? "auto"
|
||||
: v.live_resolution ? "1" : "0");
|
||||
|
||||
@@ -34,6 +34,18 @@ inline bool IsFsr4(int upscaler) {
|
||||
return upscaler == UpscalerFsr4 || upscaler == UpscalerFsr411;
|
||||
}
|
||||
enum Preset : int { NativeAA = 0, Quality, Balanced, Performance, UltraPerformance, PresetCount };
|
||||
/// Texture quality (texture_quality in bbport.ini; BB_TEXTURE_QUALITY overrides): block-compressed
|
||||
/// materials reduced to save VRAM on GPUs with 2-4 GB.
|
||||
enum TextureQuality : int { TqAuto = 0, TqFull = 1, TqHalf = 2, TqQuarter = 3, TqCount = 4 };
|
||||
inline const char* TextureQualityName(int quality) {
|
||||
switch (quality) {
|
||||
case TqAuto: return "auto";
|
||||
case TqFull: return "full";
|
||||
case TqHalf: return "half";
|
||||
case TqQuarter: return "quarter";
|
||||
default: return "auto";
|
||||
}
|
||||
}
|
||||
/// NVIDIA's DLSS render-preset hint (NVSDK_NGX_DLSS_Hint_Render_Preset): 0 = the driver's
|
||||
/// choice, 10 = J (less ghosting), 11 = K (the transformer model, recommended).
|
||||
enum DlssModel : int { DlssModelDefault = 0, DlssModelJ = 10, DlssModelK = 11 };
|
||||
@@ -114,6 +126,8 @@ struct Values {
|
||||
std::atomic<int> model_lod{0}; ///< -2 highest .. 2 lowest, 0 the game's
|
||||
std::atomic<int> prep_workers{-1}; ///< -1 default (Available/2), 0 off, 1..16 explicit workers
|
||||
std::atomic<int> async_pipelines{-1}; ///< -1 default (auto), 0 off (sync on GPU thread), 1..16 workers
|
||||
std::atomic<int> texture_quality{TqAuto}; ///< auto, full, half, quarter
|
||||
std::atomic<bool> save_auto_backup{true}; ///< auto-backup safepoint on save unmount
|
||||
/// Live engine clock (probe.c hook). live_fps/target_fps/max_dt_hz persist; the rest is
|
||||
/// runtime status for the overlay.
|
||||
std::atomic<bool> live_fps{true};
|
||||
@@ -144,6 +158,7 @@ struct Values {
|
||||
int startup_model_lod = 0;
|
||||
int startup_prep_workers = -1;
|
||||
int startup_async_pipelines = -1;
|
||||
int startup_texture_quality = TqAuto;
|
||||
int startup_output_res = OutputDefault;
|
||||
int startup_live_resolution = 0;
|
||||
};
|
||||
|
||||
@@ -86,6 +86,8 @@ enum : std::uint64_t {
|
||||
StableMotionHash = 1ull << 0,
|
||||
/// The crash guards added to the recorder and the guest fault handler (set: off).
|
||||
DefensiveGuards = 1ull << 1,
|
||||
/// Pages written by the PS4 red-zone vertex generators stay writable (set: off).
|
||||
StickyFaultPages = 1ull << 2,
|
||||
};
|
||||
inline bool Disabled(std::uint64_t bit) {
|
||||
return (__atomic_load_n(&runtime_disabled_optimizations, __ATOMIC_RELAXED) & bit) != 0;
|
||||
@@ -100,6 +102,8 @@ namespace BbStats {
|
||||
/// Guest writes caught by page protection, and pages currently left unprotected as hot.
|
||||
inline std::atomic<std::uint64_t> tracker_faults{0};
|
||||
inline std::atomic<std::int64_t> hot_pages{0};
|
||||
/// Pages permanently kept writable for the red-zone vertex generators (BB_STICKY_FAULT_PAGES).
|
||||
inline std::atomic<std::uint64_t> sticky_pages{0};
|
||||
/// Stall diagnostics (BB_FRAME_STATS): per-frame deltas printed for frames over 40 ms.
|
||||
inline std::atomic<std::uint64_t> images_registered{0};
|
||||
inline std::atomic<std::uint64_t> image_upload_bytes{0};
|
||||
|
||||
@@ -1,204 +1,207 @@
|
||||
@echo off
|
||||
setlocal
|
||||
|
||||
cd /d "%~dp0"
|
||||
|
||||
REM Find Python 3 (prefer bundled embedded Python if present)
|
||||
if defined PYTHON goto found_python
|
||||
if exist "%~dp0python-embed\python.exe" set "PYTHON=%~dp0python-embed\python.exe" & goto found_python
|
||||
if exist "%~dp0python\python.exe" set "PYTHON=%~dp0python\python.exe" & goto found_python
|
||||
for %%P in (
|
||||
"C:\Program Files\Python314\python.exe"
|
||||
"C:\Program Files\Python313\python.exe"
|
||||
"C:\Program Files\Python312\python.exe"
|
||||
"C:\Program Files\Python311\python.exe"
|
||||
"C:\Program Files\Python310\python.exe"
|
||||
"%LocalAppData%\Programs\Python\Python314\python.exe"
|
||||
"%LocalAppData%\Programs\Python\Python313\python.exe"
|
||||
"%LocalAppData%\Programs\Python\Python312\python.exe"
|
||||
"%LocalAppData%\Programs\Python\Python311\python.exe"
|
||||
"%LocalAppData%\Programs\Python\Python310\python.exe"
|
||||
) do if not defined PYTHON if exist %%P set "PYTHON=%%~P"
|
||||
if defined PYTHON goto found_python
|
||||
for /f "delims=" %%i in ('where python.exe 2^>nul') do if not "%%~dpi"=="%LocalAppData%\Microsoft\WindowsApps\" if not defined PYTHON set "PYTHON=%%i"
|
||||
if defined PYTHON goto found_python
|
||||
for /f "tokens=*" %%i in ('where py.exe 2^>nul') do if not defined PYTHON set "PYTHON=%%i"
|
||||
if not defined PYTHON set "PYTHON=python"
|
||||
:found_python
|
||||
|
||||
REM Writable directory for generated files (out/), saves (user/) and bbport.ini
|
||||
if "%BB_DATA_DIR%"=="" set "BB_DATA_DIR=."
|
||||
set "out=%BB_DATA_DIR%\out"
|
||||
if not exist "%out%" mkdir "%out%"
|
||||
if "%BB_CONFIG%"=="" set "BB_CONFIG=%BB_DATA_DIR%\bbport.ini"
|
||||
|
||||
REM An in-game restart or a crash can leave the old bbport.exe alive with gigabytes of RAM and
|
||||
REM VRAM. Give it a few seconds to exit, then terminate it (the guard never fails the launch).
|
||||
if exist scripts\bb_guard.py "%PYTHON%" scripts\bb_guard.py --exe-dir out --wait 10
|
||||
|
||||
REM Game directory containing eboot.bin (no parenthesized blocks so paths with '(' or ')' never break cmd.exe)
|
||||
set "EXTRA_ARGS="
|
||||
:arg_loop
|
||||
if "%~1"=="" goto arg_done
|
||||
@echo off
|
||||
setlocal
|
||||
|
||||
cd /d "%~dp0"
|
||||
|
||||
REM Find Python 3 (prefer bundled embedded Python if present)
|
||||
if defined PYTHON goto found_python
|
||||
if exist "%~dp0python-embed\python.exe" set "PYTHON=%~dp0python-embed\python.exe" & goto found_python
|
||||
if exist "%~dp0python\python.exe" set "PYTHON=%~dp0python\python.exe" & goto found_python
|
||||
for %%P in (
|
||||
"C:\Program Files\Python314\python.exe"
|
||||
"C:\Program Files\Python313\python.exe"
|
||||
"C:\Program Files\Python312\python.exe"
|
||||
"C:\Program Files\Python311\python.exe"
|
||||
"C:\Program Files\Python310\python.exe"
|
||||
"%LocalAppData%\Programs\Python\Python314\python.exe"
|
||||
"%LocalAppData%\Programs\Python\Python313\python.exe"
|
||||
"%LocalAppData%\Programs\Python\Python312\python.exe"
|
||||
"%LocalAppData%\Programs\Python\Python311\python.exe"
|
||||
"%LocalAppData%\Programs\Python\Python310\python.exe"
|
||||
) do if not defined PYTHON if exist %%P set "PYTHON=%%~P"
|
||||
if defined PYTHON goto found_python
|
||||
for /f "delims=" %%i in ('where python.exe 2^>nul') do if not "%%~dpi"=="%LocalAppData%\Microsoft\WindowsApps\" if not defined PYTHON set "PYTHON=%%i"
|
||||
if defined PYTHON goto found_python
|
||||
for /f "tokens=*" %%i in ('where py.exe 2^>nul') do if not defined PYTHON set "PYTHON=%%i"
|
||||
if not defined PYTHON set "PYTHON=python"
|
||||
:found_python
|
||||
|
||||
REM Writable directory for generated files (out/), saves (user/) and bbport.ini
|
||||
if "%BB_DATA_DIR%"=="" set "BB_DATA_DIR=."
|
||||
set "out=%BB_DATA_DIR%\out"
|
||||
if not exist "%out%" mkdir "%out%"
|
||||
if "%BB_CONFIG%"=="" set "BB_CONFIG=%BB_DATA_DIR%\bbport.ini"
|
||||
|
||||
REM An in-game restart or a crash can leave the old bbport.exe alive with gigabytes of RAM and
|
||||
REM VRAM. Give it a few seconds to exit, then terminate it (the guard never fails the launch).
|
||||
if exist scripts\bb_guard.py "%PYTHON%" scripts\bb_guard.py --exe-dir out --wait 10
|
||||
|
||||
REM Game directory containing eboot.bin (no parenthesized blocks so paths with '(' or ')' never break cmd.exe)
|
||||
set "EXTRA_ARGS="
|
||||
:arg_loop
|
||||
if "%~1"=="" goto arg_done
|
||||
if exist "%~1\eboot.bin" goto arg_game_dir
|
||||
if exist "%~1\eboot.elf" goto arg_game_dir
|
||||
if /i "%~nx1"=="eboot.bin" if exist "%~1" goto arg_eboot_file
|
||||
set EXTRA_ARGS=%EXTRA_ARGS% %1
|
||||
shift
|
||||
goto arg_loop
|
||||
:arg_game_dir
|
||||
set "BB_GAME_DIR=%~1"
|
||||
shift
|
||||
goto arg_loop
|
||||
:arg_eboot_file
|
||||
set "BB_GAME_DIR=%~dp1"
|
||||
shift
|
||||
goto arg_loop
|
||||
:arg_done
|
||||
|
||||
if "%BB_GAME_DIR%"=="" set "BB_GAME_DIR=..\CUSA03173"
|
||||
set "game=%BB_GAME_DIR%"
|
||||
REM Strip trailing backslash if present
|
||||
if "%game:~-1%"=="\" set "game=%game:~0,-1%"
|
||||
if /i "%~nx1"=="eboot.elf" if exist "%~1" goto arg_eboot_file
|
||||
set EXTRA_ARGS=%EXTRA_ARGS% %1
|
||||
shift
|
||||
goto arg_loop
|
||||
:arg_game_dir
|
||||
set "BB_GAME_DIR=%~1"
|
||||
shift
|
||||
goto arg_loop
|
||||
:arg_eboot_file
|
||||
set "BB_GAME_DIR=%~dp1"
|
||||
shift
|
||||
goto arg_loop
|
||||
:arg_done
|
||||
|
||||
if "%BB_GAME_DIR%"=="" set "BB_GAME_DIR=..\CUSA03173"
|
||||
set "game=%BB_GAME_DIR%"
|
||||
REM Strip trailing backslash if present
|
||||
if "%game:~-1%"=="\" set "game=%game:~0,-1%"
|
||||
if exist "%game%\eboot.bin" goto found_eboot
|
||||
echo No eboot.bin in "%game%" ^(set BB_GAME_DIR or pass path to run.bat^). >&2
|
||||
exit /b 1
|
||||
:found_eboot
|
||||
|
||||
set "mods_dir=%BB_MODS_DIR%"
|
||||
if "%mods_dir%"=="" set "mods_dir=%BB_DATA_DIR%\mods"
|
||||
set "mods_config=%BB_MODS_CONFIG%"
|
||||
if "%mods_config%"=="" set "mods_config=%BB_DATA_DIR%\mods.json"
|
||||
set "mods_enabled=%BB_MODS_ENABLED%"
|
||||
if "%mods_enabled%"=="" set "mods_enabled=1"
|
||||
|
||||
REM NOTE: for /f ('"exe" args "more args"') trips cmd's quote-stripping rule and prints
|
||||
REM "The filename, directory name, or volume label syntax is incorrect." Also, cmd's for /f
|
||||
REM in ("path") parser breaks if the path itself contains ')'. Write to a temp file in %out%
|
||||
REM and read it via pushd using a plain filename so ')' in paths cannot break the loop.
|
||||
set "mods_result=%out%\mods_game.txt"
|
||||
if exist "%mods_result%" del "%mods_result%"
|
||||
"%PYTHON%" scripts\mods.py "%game%" --out "%out%" --mods-dir "%mods_dir%" --config "%mods_config%" --enabled "%mods_enabled%" > "%mods_result%"
|
||||
pushd "%out%"
|
||||
if exist "mods_game.txt" for /f "usebackq delims=" %%g in ("mods_game.txt") do set "game=%%g"
|
||||
popd
|
||||
|
||||
"%PYTHON%" scripts\prepare.py "%game%" --out "%out%"
|
||||
if errorlevel 1 exit /b 1
|
||||
|
||||
"%PYTHON%" scripts\link_libc.py "%game%" --out "%out%"
|
||||
if errorlevel 1 exit /b 1
|
||||
|
||||
"%PYTHON%" scripts\link_modules.py "%game%" --out "%out%"
|
||||
if errorlevel 1 exit /b 1
|
||||
|
||||
set "sku=%BB_CONTENT_SKU%"
|
||||
if "%sku%"=="" set "sku=full"
|
||||
"%PYTHON%" scripts\content_profile.py "%game%" --out "%out%" --sku "%sku%"
|
||||
if errorlevel 1 exit /b 1
|
||||
|
||||
if "%BB_AUTO_RENDER_RES%"=="1" (
|
||||
set "BB_RENDER_RES="
|
||||
set "BB_OUTPUT_RES="
|
||||
set "BB_AUTO_RENDER_RES="
|
||||
)
|
||||
|
||||
if "%BB_FPS%"=="30" set "BB_LIVE_FPS=0"
|
||||
if "%BB_LIVE_FPS%"=="1" (
|
||||
set "fps=live"
|
||||
if "%BB_VBLANK_HZ%"=="" set "BB_VBLANK_HZ=0"
|
||||
) else (
|
||||
if "%BB_FPS%"=="" set "BB_FPS=uncap"
|
||||
set "fps=%BB_FPS%"
|
||||
)
|
||||
|
||||
REM The scaled sizes go through a file, like mods.py above: read with for /f ('"%PYTHON%" ...
|
||||
REM "%BB_CONFIG%"') the command lost its quotes and printed nothing, so an output other than
|
||||
REM 1080p never got its startup patch (or its direct memory) and always ran live.
|
||||
set "scaled_render="
|
||||
set "scaled_output="
|
||||
set "scaled_result=%out%\scaled_sizes.txt"
|
||||
if exist "%scaled_result%" del "%scaled_result%"
|
||||
if "%BB_RENDER_RES%"=="" "%PYTHON%" scripts\patches.py --print-scaled --settings "%BB_CONFIG%" > "%scaled_result%" 2>nul
|
||||
pushd "%out%"
|
||||
if exist "scaled_sizes.txt" for /f "usebackq tokens=1,2" %%a in ("scaled_sizes.txt") do (
|
||||
set "scaled_render=%%a"
|
||||
set "scaled_output=%%b"
|
||||
)
|
||||
popd
|
||||
|
||||
REM One statement per line: inside a ( ) block %live% keeps the value it had before the block,
|
||||
REM which turned live_resolution=1 and auto from bbport.ini back into 0.
|
||||
set "live=0"
|
||||
if "%scaled_output%"=="" goto live_chosen
|
||||
if not "%BB_LIVE_RES%"=="" set "live=%BB_LIVE_RES%"
|
||||
if not "%live%"=="0" goto check_live_auto
|
||||
if not exist "%BB_CONFIG%" goto check_live_auto
|
||||
copy /y "%BB_CONFIG%" "%out%\bbport_ini.tmp" >nul 2>nul
|
||||
pushd "%out%"
|
||||
if exist "bbport_ini.tmp" for /f "usebackq tokens=1,2 delims==" %%a in ("bbport_ini.tmp") do if "%%a"=="live_resolution" set "live=%%b"
|
||||
if exist "bbport_ini.tmp" del /f /q "bbport_ini.tmp" >nul 2>nul
|
||||
popd
|
||||
:check_live_auto
|
||||
if "%live%"=="auto" for /f "delims=" %%c in ('out\bb-gpu-capabilities.exe --live-resolution 2^>nul') do set "live=%%c"
|
||||
if not "%live%"=="1" set "live=0"
|
||||
:live_chosen
|
||||
|
||||
if "%live%"=="1" (
|
||||
echo Output %scaled_output%: live resolution changes ^(live_resolution=0: startup patch^)
|
||||
) else if not "%scaled_output%"=="" (
|
||||
set "BB_RENDER_RES=%scaled_render%"
|
||||
set "BB_OUTPUT_RES=%scaled_output%"
|
||||
set "BB_AUTO_RENDER_RES=1"
|
||||
if "%BB_DMEM_MB%"=="" set "BB_DMEM_MB=9152"
|
||||
)
|
||||
REM Outside the block, so the message shows the memory just set.
|
||||
if defined BB_AUTO_RENDER_RES echo Output %scaled_output%: scene %scaled_render%, direct memory %BB_DMEM_MB% MiB ^(live_resolution=1: live changes^)
|
||||
|
||||
REM Explicit launcher resolutions skip scaled_output above, but patches.py still adds
|
||||
REM Increased Graphics Heap Sizes above 1080p. Match that patch's direct-memory budget.
|
||||
set "render_pixels=0"
|
||||
if defined BB_RENDER_RES for /f "tokens=1,2 delims=xX" %%a in ("%BB_RENDER_RES%") do set /a "render_pixels=%%a*%%b" >nul 2>nul
|
||||
if %render_pixels% GTR 2073600 if not defined BB_DMEM_MB set "BB_DMEM_MB=9152"
|
||||
if %render_pixels% GTR 2073600 echo Render %BB_RENDER_RES%: direct memory %BB_DMEM_MB% MiB
|
||||
|
||||
set "patches_dir=%BB_PATCHES_DIR%"
|
||||
if "%patches_dir%"=="" set "patches_dir=%BB_DATA_DIR%\patches"
|
||||
set "patches_config=%BB_PATCHES_CONFIG%"
|
||||
if "%patches_config%"=="" set "patches_config=%BB_DATA_DIR%\patches.json"
|
||||
|
||||
"%PYTHON%" scripts\patches.py --out "%out%" --fps "%fps%" --extra "%BB_PATCHES%" --settings "%BB_CONFIG%" --game-dir "%game%" --render-res "%BB_RENDER_RES%" --output-res "%BB_OUTPUT_RES%" --patches-dir "%patches_dir%" --patches-config "%patches_config%"
|
||||
if errorlevel 1 exit /b 1
|
||||
|
||||
if "%BB_VBLANK_HZ%"=="" (
|
||||
if "%fps%"=="live" (
|
||||
set "BB_VBLANK_HZ=0"
|
||||
) else if "%fps%"=="uncap" (
|
||||
set "BB_VBLANK_HZ=0"
|
||||
) else if "%fps%"=="90" (
|
||||
set "BB_VBLANK_HZ=90"
|
||||
) else (
|
||||
set "BB_VBLANK_HZ=60"
|
||||
)
|
||||
)
|
||||
|
||||
if exist "out\bbport.exe" goto found_probe
|
||||
if exist "build.bat" (
|
||||
call build.bat
|
||||
if errorlevel 1 exit /b 1
|
||||
goto found_probe
|
||||
)
|
||||
echo [ERROR] out\bbport.exe not found. >&2
|
||||
exit /b 1
|
||||
:found_probe
|
||||
|
||||
set "probe=out\bbport.exe"
|
||||
set "user_dir=%BB_USER_DIR%"
|
||||
if "%user_dir%"=="" set "user_dir=%BB_DATA_DIR%\user"
|
||||
set "timeout=%BB_TIMEOUT%"
|
||||
if exist "%game%\eboot.elf" goto found_eboot
|
||||
echo No eboot.bin or eboot.elf in "%game%" ^(set BB_GAME_DIR or pass path to run.bat^). >&2
|
||||
exit /b 1
|
||||
:found_eboot
|
||||
|
||||
set "mods_dir=%BB_MODS_DIR%"
|
||||
if "%mods_dir%"=="" set "mods_dir=%BB_DATA_DIR%\mods"
|
||||
set "mods_config=%BB_MODS_CONFIG%"
|
||||
if "%mods_config%"=="" set "mods_config=%BB_DATA_DIR%\mods.json"
|
||||
set "mods_enabled=%BB_MODS_ENABLED%"
|
||||
if "%mods_enabled%"=="" set "mods_enabled=1"
|
||||
|
||||
REM NOTE: for /f ('"exe" args "more args"') trips cmd's quote-stripping rule and prints
|
||||
REM "The filename, directory name, or volume label syntax is incorrect." Also, cmd's for /f
|
||||
REM in ("path") parser breaks if the path itself contains ')'. Write to a temp file in %out%
|
||||
REM and read it via pushd using a plain filename so ')' in paths cannot break the loop.
|
||||
set "mods_result=%out%\mods_game.txt"
|
||||
if exist "%mods_result%" del "%mods_result%"
|
||||
"%PYTHON%" scripts\mods.py "%game%" --out "%out%" --mods-dir "%mods_dir%" --config "%mods_config%" --enabled "%mods_enabled%" > "%mods_result%"
|
||||
pushd "%out%"
|
||||
if exist "mods_game.txt" for /f "usebackq delims=" %%g in ("mods_game.txt") do set "game=%%g"
|
||||
popd
|
||||
|
||||
"%PYTHON%" scripts\prepare.py "%game%" --out "%out%"
|
||||
if errorlevel 1 exit /b 1
|
||||
|
||||
"%PYTHON%" scripts\link_libc.py "%game%" --out "%out%"
|
||||
if errorlevel 1 exit /b 1
|
||||
|
||||
"%PYTHON%" scripts\link_modules.py "%game%" --out "%out%"
|
||||
if errorlevel 1 exit /b 1
|
||||
|
||||
set "sku=%BB_CONTENT_SKU%"
|
||||
if "%sku%"=="" set "sku=full"
|
||||
"%PYTHON%" scripts\content_profile.py "%game%" --out "%out%" --sku "%sku%"
|
||||
if errorlevel 1 exit /b 1
|
||||
|
||||
if "%BB_AUTO_RENDER_RES%"=="1" (
|
||||
set "BB_RENDER_RES="
|
||||
set "BB_OUTPUT_RES="
|
||||
set "BB_AUTO_RENDER_RES="
|
||||
)
|
||||
|
||||
if "%BB_FPS%"=="30" set "BB_LIVE_FPS=0"
|
||||
if "%BB_LIVE_FPS%"=="1" (
|
||||
set "fps=live"
|
||||
if "%BB_VBLANK_HZ%"=="" set "BB_VBLANK_HZ=0"
|
||||
) else (
|
||||
if "%BB_FPS%"=="" set "BB_FPS=uncap"
|
||||
set "fps=%BB_FPS%"
|
||||
)
|
||||
|
||||
REM The scaled sizes go through a file, like mods.py above: read with for /f ('"%PYTHON%" ...
|
||||
REM "%BB_CONFIG%"') the command lost its quotes and printed nothing, so an output other than
|
||||
REM 1080p never got its startup patch (or its direct memory) and always ran live.
|
||||
set "scaled_render="
|
||||
set "scaled_output="
|
||||
set "scaled_result=%out%\scaled_sizes.txt"
|
||||
if exist "%scaled_result%" del "%scaled_result%"
|
||||
if "%BB_RENDER_RES%"=="" "%PYTHON%" scripts\patches.py --print-scaled --settings "%BB_CONFIG%" > "%scaled_result%" 2>nul
|
||||
pushd "%out%"
|
||||
if exist "scaled_sizes.txt" for /f "usebackq tokens=1,2" %%a in ("scaled_sizes.txt") do (
|
||||
set "scaled_render=%%a"
|
||||
set "scaled_output=%%b"
|
||||
)
|
||||
popd
|
||||
|
||||
REM One statement per line: inside a ( ) block %live% keeps the value it had before the block,
|
||||
REM which turned live_resolution=1 and auto from bbport.ini back into 0.
|
||||
set "live=0"
|
||||
if "%scaled_output%"=="" goto live_chosen
|
||||
if not "%BB_LIVE_RES%"=="" set "live=%BB_LIVE_RES%"
|
||||
if not "%live%"=="0" goto check_live_auto
|
||||
if not exist "%BB_CONFIG%" goto check_live_auto
|
||||
copy /y "%BB_CONFIG%" "%out%\bbport_ini.tmp" >nul 2>nul
|
||||
pushd "%out%"
|
||||
if exist "bbport_ini.tmp" for /f "usebackq tokens=1,2 delims==" %%a in ("bbport_ini.tmp") do if "%%a"=="live_resolution" set "live=%%b"
|
||||
if exist "bbport_ini.tmp" del /f /q "bbport_ini.tmp" >nul 2>nul
|
||||
popd
|
||||
:check_live_auto
|
||||
if "%live%"=="auto" for /f "delims=" %%c in ('out\bb-gpu-capabilities.exe --live-resolution 2^>nul') do set "live=%%c"
|
||||
if not "%live%"=="1" set "live=0"
|
||||
:live_chosen
|
||||
|
||||
if "%live%"=="1" (
|
||||
echo Output %scaled_output%: live resolution changes ^(live_resolution=0: startup patch^)
|
||||
) else if not "%scaled_output%"=="" (
|
||||
set "BB_RENDER_RES=%scaled_render%"
|
||||
set "BB_OUTPUT_RES=%scaled_output%"
|
||||
set "BB_AUTO_RENDER_RES=1"
|
||||
if "%BB_DMEM_MB%"=="" set "BB_DMEM_MB=9152"
|
||||
)
|
||||
REM Outside the block, so the message shows the memory just set.
|
||||
if defined BB_AUTO_RENDER_RES echo Output %scaled_output%: scene %scaled_render%, direct memory %BB_DMEM_MB% MiB ^(live_resolution=1: live changes^)
|
||||
|
||||
REM Explicit launcher resolutions skip scaled_output above, but patches.py still adds
|
||||
REM Increased Graphics Heap Sizes above 1080p. Match that patch's direct-memory budget.
|
||||
set "render_pixels=0"
|
||||
if defined BB_RENDER_RES for /f "tokens=1,2 delims=xX" %%a in ("%BB_RENDER_RES%") do set /a "render_pixels=%%a*%%b" >nul 2>nul
|
||||
if %render_pixels% GTR 2073600 if not defined BB_DMEM_MB set "BB_DMEM_MB=9152"
|
||||
if %render_pixels% GTR 2073600 echo Render %BB_RENDER_RES%: direct memory %BB_DMEM_MB% MiB
|
||||
|
||||
set "patches_dir=%BB_PATCHES_DIR%"
|
||||
if "%patches_dir%"=="" set "patches_dir=%BB_DATA_DIR%\patches"
|
||||
set "patches_config=%BB_PATCHES_CONFIG%"
|
||||
if "%patches_config%"=="" set "patches_config=%BB_DATA_DIR%\patches.json"
|
||||
|
||||
"%PYTHON%" scripts\patches.py --out "%out%" --fps "%fps%" --extra "%BB_PATCHES%" --settings "%BB_CONFIG%" --game-dir "%game%" --render-res "%BB_RENDER_RES%" --output-res "%BB_OUTPUT_RES%" --patches-dir "%patches_dir%" --patches-config "%patches_config%"
|
||||
if errorlevel 1 exit /b 1
|
||||
|
||||
if "%BB_VBLANK_HZ%"=="" (
|
||||
if "%fps%"=="live" (
|
||||
set "BB_VBLANK_HZ=0"
|
||||
) else if "%fps%"=="uncap" (
|
||||
set "BB_VBLANK_HZ=0"
|
||||
) else if "%fps%"=="90" (
|
||||
set "BB_VBLANK_HZ=90"
|
||||
) else (
|
||||
set "BB_VBLANK_HZ=60"
|
||||
)
|
||||
)
|
||||
|
||||
if exist "out\bbport.exe" goto found_probe
|
||||
if exist "build.bat" (
|
||||
call build.bat
|
||||
if errorlevel 1 exit /b 1
|
||||
goto found_probe
|
||||
)
|
||||
echo [ERROR] out\bbport.exe not found. >&2
|
||||
exit /b 1
|
||||
:found_probe
|
||||
|
||||
set "probe=out\bbport.exe"
|
||||
set "user_dir=%BB_USER_DIR%"
|
||||
if "%user_dir%"=="" set "user_dir=%BB_DATA_DIR%\user"
|
||||
set "timeout=%BB_TIMEOUT%"
|
||||
if "%timeout%"=="" set "timeout=0"
|
||||
if not defined BB_SHADER_RECIPES if exist "%~dp0shader_recipes.bin" set "BB_SHADER_RECIPES=%~dp0shader_recipes.bin"
|
||||
if not defined BB_SHADER_GAME_DIR set "BB_SHADER_GAME_DIR=%game%"
|
||||
|
||||
echo Launching Bloodborne PC (%probe%)...
|
||||
"%probe%" "%out%\boot-linked.bin" --content-profile "%out%\content.bin" --patches "%out%\patches.bin" --app0 "%game%" --user "%user_dir%" --timeout "%timeout%" %EXTRA_ARGS%
|
||||
exit /b %ERRORLEVEL%
|
||||
if not defined BB_SHADER_GAME_DIR set "BB_SHADER_GAME_DIR=%game%"
|
||||
|
||||
echo Launching Bloodborne PC (%probe%)...
|
||||
"%probe%" "%out%\boot-linked.bin" --content-profile "%out%\content.bin" --patches "%out%\patches.bin" --app0 "%game%" --user "%user_dir%" --timeout "%timeout%" %EXTRA_ARGS%
|
||||
exit /b %ERRORLEVEL%
|
||||
+73
-64
@@ -1,66 +1,75 @@
|
||||
#!/usr/bin/env python3
|
||||
"""The game files bbport runs: Bloodborne CUSA03173 with the 1.09 update merged in.
|
||||
|
||||
Other versions start and then fail inside the game's code (the base game 1.00 faults at guest
|
||||
offset 0x20348b8): hooks and patches use the addresses of this one executable. The check compares
|
||||
the loaded executable image, whose hash is the same whatever tool dumped it (the SELF headers
|
||||
around it differ). BB_SKIP_GAME_CHECK=1 skips it.
|
||||
"""
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
SUPPORTED_TITLE = 'CUSA03173'
|
||||
SUPPORTED_VERSION = '01.09'
|
||||
SUPPORTED_IMAGE = '071df19c8880086d97182dbc057bc8cb37badaca57d9112683836b24a0444c0a'
|
||||
#!/usr/bin/env python3
|
||||
"""The game files bbport runs: Bloodborne CUSA03173 with the 1.09 update merged in.
|
||||
|
||||
Other versions start and then fail inside the game's code (the base game 1.00 faults at guest
|
||||
offset 0x20348b8): hooks and patches use the addresses of this one executable. The check compares
|
||||
the loaded executable image, whose hash is the same whatever tool dumped it (the SELF headers
|
||||
around it differ). BB_SKIP_GAME_CHECK=1 skips it.
|
||||
"""
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
SUPPORTED_TITLE = 'CUSA03173'
|
||||
SUPPORTED_VERSION = '01.09'
|
||||
SUPPORTED_IMAGE = '071df19c8880086d97182dbc057bc8cb37badaca57d9112683836b24a0444c0a'
|
||||
|
||||
|
||||
def find_eboot(game):
|
||||
for candidate in ('eboot.bin', 'eboot.elf'):
|
||||
p = Path(game) / candidate
|
||||
if p.is_file():
|
||||
return p
|
||||
return Path(game) / 'eboot.bin'
|
||||
|
||||
|
||||
def image_sha256(game):
|
||||
"""The SHA-256 of eboot.bin's loaded image (its loadable segments at their addresses)."""
|
||||
from prepare import parse_self, span # same directory
|
||||
elf, header, ph, _segments, _missing = parse_self((Path(game) / 'eboot.bin').read_bytes())
|
||||
loads = [p for p in ph if p['type'] in (1, 0x61000010)]
|
||||
image = bytearray(max(p['vaddr'] + p['memsz'] for p in loads))
|
||||
for p in loads:
|
||||
image[p['vaddr']:p['vaddr'] + p['filesz']] = span(elf, p['offset'], p['filesz'])
|
||||
return hashlib.sha256(image).hexdigest()
|
||||
|
||||
|
||||
def problem(game, image_hash=None):
|
||||
"""None for the supported game, else (kind, title, version): kind is 'missing_update' (base
|
||||
game or an older update), 'wrong_eboot' (param.sfo says 1.09, eboot.bin is another version),
|
||||
'other_title' (another edition or region) or 'unreadable'."""
|
||||
if os.environ.get('BB_SKIP_GAME_CHECK') == '1':
|
||||
return None
|
||||
from prepare import sfo
|
||||
try:
|
||||
info = sfo((Path(game) / 'sce_sys/param.sfo').read_bytes())
|
||||
except (OSError, ValueError, IndexError):
|
||||
info = {}
|
||||
title, version = info.get('TITLE_ID', '?'), info.get('APP_VER', '?')
|
||||
try:
|
||||
if (image_hash or image_sha256(game)) == SUPPORTED_IMAGE:
|
||||
return None
|
||||
except (OSError, ValueError, IndexError, StopIteration, KeyError):
|
||||
return 'unreadable', title, version
|
||||
if title != SUPPORTED_TITLE:
|
||||
return 'other_title', title, version
|
||||
if version != SUPPORTED_VERSION:
|
||||
return 'missing_update', title, version
|
||||
return 'wrong_eboot', title, version
|
||||
|
||||
|
||||
def explain(kind, title, version):
|
||||
"""What is wrong and what to do, for the log (English)."""
|
||||
found = f'Found {title} version {version} (sce_sys/param.sfo).'
|
||||
return {
|
||||
'missing_update': f'{found} bbport needs the 1.09 update merged into the game folder: copy '
|
||||
'everything from the dumped 1.09 update (e.g. CUSA03173-patch) into the '
|
||||
'game folder, replacing files (eboot.bin and sce_sys too).',
|
||||
'wrong_eboot': f'{found} param.sfo is from 1.09 but eboot.bin is not: copy eboot.bin from '
|
||||
'the dumped 1.09 update into the game folder, replacing the old one.',
|
||||
'other_title': f'{found} Only Bloodborne {SUPPORTED_TITLE} with update 1.09 is supported '
|
||||
'for now; other editions and regions have a different executable.',
|
||||
'unreadable': f'{found} eboot.bin could not be read as a decrypted PS4 executable: dump '
|
||||
'the game and the 1.09 update again.',
|
||||
}[kind]
|
||||
def image_sha256(game):
|
||||
"""The SHA-256 of eboot.bin's loaded image (its loadable segments at their addresses)."""
|
||||
from prepare import parse_self, span # same directory
|
||||
eboot_path = find_eboot(game)
|
||||
elf, header, ph, _segments, _missing = parse_self(eboot_path.read_bytes(), name=eboot_path.name)
|
||||
loads = [p for p in ph if p['type'] in (1, 0x61000010)]
|
||||
image = bytearray(max(p['vaddr'] + p['memsz'] for p in loads))
|
||||
for p in loads:
|
||||
image[p['vaddr']:p['vaddr'] + p['filesz']] = span(elf, p['offset'], p['filesz'])
|
||||
return hashlib.sha256(image).hexdigest()
|
||||
|
||||
|
||||
def problem(game, image_hash=None):
|
||||
"""None for the supported game, else (kind, title, version): kind is 'missing_update' (base
|
||||
game or an older update), 'wrong_eboot' (param.sfo says 1.09, eboot.bin is another version),
|
||||
'other_title' (another edition or region) or 'unreadable'."""
|
||||
if os.environ.get('BB_SKIP_GAME_CHECK') == '1':
|
||||
return None
|
||||
from prepare import sfo
|
||||
try:
|
||||
info = sfo((Path(game) / 'sce_sys/param.sfo').read_bytes())
|
||||
except (OSError, ValueError, IndexError):
|
||||
info = {}
|
||||
title, version = info.get('TITLE_ID', '?'), info.get('APP_VER', '?')
|
||||
try:
|
||||
if (image_hash or image_sha256(game)) == SUPPORTED_IMAGE:
|
||||
return None
|
||||
except (OSError, ValueError, IndexError, StopIteration, KeyError):
|
||||
return 'unreadable', title, version
|
||||
if title != SUPPORTED_TITLE:
|
||||
return 'other_title', title, version
|
||||
if version != SUPPORTED_VERSION:
|
||||
return 'missing_update', title, version
|
||||
return 'wrong_eboot', title, version
|
||||
|
||||
|
||||
def explain(kind, title, version):
|
||||
"""What is wrong and what to do, for the log (English)."""
|
||||
found = f'Found {title} version {version} (sce_sys/param.sfo).'
|
||||
return {
|
||||
'missing_update': f'{found} bbport needs the 1.09 update merged into the game folder: copy '
|
||||
'everything from the dumped 1.09 update (e.g. CUSA03173-patch) into the '
|
||||
'game folder, replacing files (eboot.bin and sce_sys too).',
|
||||
'wrong_eboot': f'{found} param.sfo is from 1.09 but eboot.bin is not: copy eboot.bin from '
|
||||
'the dumped 1.09 update into the game folder, replacing the old one.',
|
||||
'other_title': f'{found} Only Bloodborne {SUPPORTED_TITLE} with update 1.09 is supported '
|
||||
'for now; other editions and regions have a different executable.',
|
||||
'unreadable': f'{found} eboot.bin could not be read as a decrypted PS4 executable: dump '
|
||||
'the game and the 1.09 update again.',
|
||||
}[kind]
|
||||
+235
-214
@@ -1,214 +1,235 @@
|
||||
"""Link the user's plaintext libc into a separate, reproducible probe image.
|
||||
|
||||
The original boot.bin remains usable. Symbols are matched by library/module
|
||||
identity, versions and NID, not by dump-local suffix or NID alone.
|
||||
"""
|
||||
import collections
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import struct
|
||||
import sys
|
||||
from prepare import parse_self, span, unpack
|
||||
|
||||
|
||||
def encode_id(value):
|
||||
alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+-'
|
||||
result = alphabet[value & 63]
|
||||
value >>= 6
|
||||
while value:
|
||||
result = alphabet[value & 63] + result
|
||||
value >>= 6
|
||||
return result
|
||||
|
||||
|
||||
def module(path):
|
||||
source = path.read_bytes()
|
||||
elf, header, ph, _, missing = parse_self(source)
|
||||
dp = next(p for p in ph if p['type'] == 2)
|
||||
dynamic = []
|
||||
for pos in range(dp['offset'], dp['offset']+dp['filesz'], 16):
|
||||
tag, value = unpack('<QQ', elf, pos)
|
||||
if not tag:
|
||||
break
|
||||
dynamic.append((tag, value))
|
||||
tags = dict(dynamic)
|
||||
lp = next(p for p in ph if p['type'] == 0x61000000)
|
||||
blob = span(elf, lp['offset'], lp['filesz'])
|
||||
strings = span(blob, tags[0x61000035], tags[0x61000037])
|
||||
def string(offset):
|
||||
span(strings, offset, 1)
|
||||
return strings[offset:strings.index(0, offset)].decode('ascii')
|
||||
libraries, modules = {}, {}
|
||||
for tag, value in dynamic:
|
||||
if tag in (0x61000013, 0x61000015, 0x6100000d, 0x6100000f):
|
||||
table = libraries if tag in (0x61000013, 0x61000015) else modules
|
||||
key = encode_id(value >> 48)
|
||||
identity = (string(value & 0xffffffff), (value >> 32) & 65535)
|
||||
if key in table and table[key] != identity:
|
||||
raise ValueError('conflicting module/library IDs')
|
||||
table[key] = identity
|
||||
def identity(name):
|
||||
parts = name.split('#')
|
||||
if len(parts) != 3:
|
||||
raise ValueError(f'unsupported symbol encoding {name!r}')
|
||||
n, lib, mod = parts
|
||||
return (n, libraries[lib], modules[mod])
|
||||
symbols = []
|
||||
table = span(blob, tags[0x61000039], tags[0x6100003f])
|
||||
for pos in range(0, len(table), 24):
|
||||
n, info, other, section, value, size = unpack('<IBBHQQ', table, pos)
|
||||
name = string(n)
|
||||
symbols.append(dict(name=name, type=info & 15, binding=info >> 4, section=section,
|
||||
value=value, size=size, identity=identity(name) if name else None))
|
||||
relocations = []
|
||||
for ot, st in ((0x61000029, 0x6100002d), (0x6100002f, 0x61000031)):
|
||||
table = span(blob, tags[ot], tags[st])
|
||||
for pos in range(0, len(table), 24):
|
||||
target, info, addend = unpack('<QQq', table, pos)
|
||||
relocations.append((target, info & 0xffffffff, info >> 32, addend))
|
||||
return dict(elf=elf, header=header, ph=ph, tags=tags, symbols=symbols,
|
||||
relocs=relocations, identity=identity, libraries=libraries, modules=modules,
|
||||
sha256=hashlib.sha256(source).hexdigest(), missing=missing)
|
||||
|
||||
|
||||
def link(game, out):
|
||||
main = module(game/'eboot.bin')
|
||||
libc = module(game/'sce_module/libc.prx')
|
||||
raw = (out/'boot.bin').read_bytes()
|
||||
magic, size, entry, ns, nr, ni, flags = unpack('<8s6Q', raw, 0)
|
||||
if magic != b'BBPROBE2':
|
||||
raise ValueError('linker expects freshly prepared BBPROBE2')
|
||||
pos = 56
|
||||
segments = [unpack('<3Q', raw, pos+i*24) for i in range(ns)]
|
||||
pos += ns*24
|
||||
names = [span(raw, pos+i*128, 128).split(b'\0',1)[0].decode() for i in range(ni)]
|
||||
pos += ni*128
|
||||
relocs = [unpack('<QQqq', raw, pos+i*32) for i in range(nr)]
|
||||
pos += nr*32
|
||||
image = bytearray(span(raw, pos, size))
|
||||
if pos+size != len(raw):
|
||||
raise ValueError('unexpected boot trailer')
|
||||
base = (size+65535) & ~65535
|
||||
loads = [p for p in libc['ph'] if p['type'] in (1,0x61000010)]
|
||||
libsize = max(p['vaddr']+p['memsz'] for p in loads)
|
||||
if base+libsize > 512*1024*1024:
|
||||
raise ValueError('linked image exceeds probe limit')
|
||||
image.extend(bytes(base+libsize-len(image)))
|
||||
def mapped(address, size=8):
|
||||
return any(p['vaddr'] <= address and address+size <= p['vaddr']+p['memsz'] for p in loads)
|
||||
for p in loads:
|
||||
if p['filesz'] > p['memsz']:
|
||||
raise ValueError('invalid libc segment size')
|
||||
image[base+p['vaddr']:base+p['vaddr']+p['filesz']] = span(libc['elf'],p['offset'],p['filesz'])
|
||||
segments.append((base+p['vaddr'],p['memsz'],p['flags']))
|
||||
# Preserve main-program spelling for the existing host resolver. New kernel
|
||||
# NIDs get the main module's spelling only if identity AND version match.
|
||||
identities = {main['identity'](name):i for i,name in enumerate(names)}
|
||||
main_libraries = {identity:key for key,identity in main['libraries'].items()}
|
||||
main_modules = {identity:key for key,identity in main['modules'].items()}
|
||||
def imported(symbol):
|
||||
key = symbol['identity']
|
||||
if key not in identities:
|
||||
n,lib,mod = key
|
||||
name = (f'{n}#{main_libraries[lib]}#{main_modules[mod]}'
|
||||
if lib in main_libraries and mod in main_modules else f'libc:{symbol["name"]}')
|
||||
identities[key] = len(names)
|
||||
names.append(name)
|
||||
return identities[key]
|
||||
tls_count = 0
|
||||
for target, kind, symid, addend in libc['relocs']:
|
||||
if not mapped(target):
|
||||
raise ValueError(f'libc relocation outside image {target:#x}')
|
||||
target += base
|
||||
if kind == 8:
|
||||
relocs.append((target,0,base+addend,0))
|
||||
elif kind == 16:
|
||||
# libc is module 2 in this probe. Descriptor offset is already stored
|
||||
# in the adjacent word; __tls_get_addr will use a separate TLS block.
|
||||
symbol = libc['symbols'][symid]
|
||||
if symbol['name'] or symbol['type'] != 3 or addend:
|
||||
raise ValueError('unsupported external TLS relocation')
|
||||
struct.pack_into('<Q',image,target,2)
|
||||
tls_count += 1
|
||||
elif kind in (1,6,7):
|
||||
symbol = libc['symbols'][symid]
|
||||
if symbol['section']:
|
||||
if symbol['section'] == 0xfff1:
|
||||
struct.pack_into('<Q',image,target,symbol['value']+addend)
|
||||
else:
|
||||
relocs.append((target,0,base+symbol['value']+addend,0))
|
||||
else:
|
||||
rk = 2 if symbol['type']==1 else 1
|
||||
if (rk==1 and addend) or not 0 <= addend < 4096:
|
||||
raise ValueError('unsupported libc import addend')
|
||||
relocs.append((target,rk,imported(symbol),addend))
|
||||
else:
|
||||
raise ValueError(f'unsupported libc relocation type {kind}')
|
||||
exports = {}
|
||||
for s in libc['symbols']:
|
||||
if s['section'] and s['binding'] in (1,2) and s['identity'] and s['type'] in (1,2):
|
||||
if not mapped(s['value'],max(1,s['size'])):
|
||||
raise ValueError('export outside libc load segments')
|
||||
if s['identity'] in exports:
|
||||
raise ValueError('ambiguous libc export')
|
||||
exports[s['identity']] = s
|
||||
bindings = []
|
||||
for identity,index in identities.items():
|
||||
if identity in exports:
|
||||
s = exports[identity]
|
||||
bindings.append((index,base+s['value'],2 if s['type']==1 else 1))
|
||||
# The eboot reads its thread pointer with `mov rax, fs:[0]` (initial-exec
|
||||
# TLS). glibc owns FS on Linux, so rewrite the segment prefix to GS; the
|
||||
# runtime points GS at each guest thread's TCB. Only executable eboot
|
||||
# segments are scanned, and only this exact 9-byte instruction.
|
||||
fs_load = bytes.fromhex('64488b042500000000')
|
||||
patched = 0
|
||||
if sys.platform != 'win32':
|
||||
for p in main['ph']:
|
||||
if p['type'] != 1 or not p['flags'] & 1:
|
||||
continue
|
||||
start, end = p['vaddr'], p['vaddr'] + p['filesz']
|
||||
at = image.find(fs_load, start, end)
|
||||
while at >= 0:
|
||||
image[at] = 0x65
|
||||
patched += 1
|
||||
at = image.find(fs_load, at + len(fs_load), end)
|
||||
main_tls = next((p for p in main['ph'] if p['type']==7), None)
|
||||
main_tls_values = (main_tls['vaddr'], main_tls['filesz'], main_tls['memsz'], main_tls['align']) if main_tls else (0,0,0,0)
|
||||
if main_tls and (main_tls['filesz'] > main_tls['memsz'] or main_tls['memsz'] > 1024*1024
|
||||
or main_tls['vaddr'] + main_tls['filesz'] > size):
|
||||
raise ValueError('unsupported eboot TLS layout')
|
||||
tls = next(p for p in libc['ph'] if p['type']==7)
|
||||
if tls['filesz'] > tls['memsz'] or not mapped(tls['vaddr'],tls['memsz']):
|
||||
raise ValueError('unsupported TLS layout')
|
||||
procparam = next(p for p in main['ph'] if p['type']==0x61000001)
|
||||
metadata = (base,libsize,base+libc['tags'][12],base+tls['vaddr'],tls['memsz'],tls['filesz'],len(bindings),procparam['vaddr'])
|
||||
with (out/'boot-libc.bin').open('wb') as f:
|
||||
f.write(struct.pack('<8s6Q',b'BBPROBE4',len(image),entry,len(segments),len(relocs),len(names),flags))
|
||||
f.write(struct.pack('<8Q',*metadata))
|
||||
f.write(struct.pack('<4Q',*main_tls_values))
|
||||
for binding in bindings: f.write(struct.pack('<3Q',*binding))
|
||||
for segment in segments: f.write(struct.pack('<3Q',*segment))
|
||||
for name in names:
|
||||
if len(name.encode())>=128: raise ValueError('name too long')
|
||||
f.write(name.encode().ljust(128,b'\0'))
|
||||
for relocation in relocs: f.write(struct.pack('<QQqq',*relocation))
|
||||
f.write(image)
|
||||
report = dict(base=hex(base),size=libsize,sha256=libc['sha256'],init=hex(metadata[2]),
|
||||
bindings=len(bindings),tls_module_id=2,fs_loads_patched=patched,
|
||||
main_tls=dict(zip(('vaddr','filesz','memsz','align'),main_tls_values)),tls_relocations=tls_count,
|
||||
tls_template_bytes=tls['filesz'],tls_memory_bytes=tls['memsz'],
|
||||
imports=names,relocation_counts=dict(collections.Counter(r[1] for r in libc['relocs'])),
|
||||
symbol_bindings=[dict(import_name=names[i],address=hex(a),kind=k) for i,a,k in bindings])
|
||||
(out/'libc-link.json').write_text(json.dumps(report,indent=2)+'\n')
|
||||
print(f'Linked native libc: base={base:#x}, {len(bindings)} fallback exports, TLS={tls["memsz"]} bytes; '
|
||||
f'eboot TLS={main_tls_values[2]} bytes, fs->gs patched={patched}')
|
||||
|
||||
|
||||
if __name__=='__main__':
|
||||
import argparse
|
||||
p=argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument('game',type=Path);p.add_argument('--out',type=Path,default=Path(__file__).resolve().parent.parent/'out')
|
||||
a=p.parse_args()
|
||||
link(a.game,a.out)
|
||||
"""Link the user's plaintext libc into a separate, reproducible probe image.
|
||||
|
||||
The original boot.bin remains usable. Symbols are matched by library/module
|
||||
identity, versions and NID, not by dump-local suffix or NID alone.
|
||||
"""
|
||||
import collections
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import struct
|
||||
import sys
|
||||
from prepare import parse_self, span, unpack
|
||||
|
||||
|
||||
def encode_id(value):
|
||||
alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+-'
|
||||
result = alphabet[value & 63]
|
||||
value >>= 6
|
||||
while value:
|
||||
result = alphabet[value & 63] + result
|
||||
value >>= 6
|
||||
return result
|
||||
|
||||
|
||||
def module(path):
|
||||
source = path.read_bytes()
|
||||
elf, header, ph, _, missing = parse_self(source)
|
||||
dp = next(p for p in ph if p['type'] == 2)
|
||||
dynamic = []
|
||||
for pos in range(dp['offset'], dp['offset']+dp['filesz'], 16):
|
||||
tag, value = unpack('<QQ', elf, pos)
|
||||
if not tag:
|
||||
break
|
||||
dynamic.append((tag, value))
|
||||
tags = dict(dynamic)
|
||||
lp = next(p for p in ph if p['type'] == 0x61000000)
|
||||
blob = span(elf, lp['offset'], lp['filesz'])
|
||||
strings = span(blob, tags[0x61000035], tags[0x61000037])
|
||||
def string(offset):
|
||||
span(strings, offset, 1)
|
||||
return strings[offset:strings.index(0, offset)].decode('ascii')
|
||||
libraries, modules = {}, {}
|
||||
for tag, value in dynamic:
|
||||
if tag in (0x61000013, 0x61000015, 0x6100000d, 0x6100000f):
|
||||
table = libraries if tag in (0x61000013, 0x61000015) else modules
|
||||
key = encode_id(value >> 48)
|
||||
identity = (string(value & 0xffffffff), (value >> 32) & 65535)
|
||||
if key in table and table[key] != identity:
|
||||
raise ValueError('conflicting module/library IDs')
|
||||
table[key] = identity
|
||||
def identity(name):
|
||||
parts = name.split('#')
|
||||
if len(parts) != 3:
|
||||
raise ValueError(f'unsupported symbol encoding {name!r}')
|
||||
n, lib, mod = parts
|
||||
return (n, libraries[lib], modules[mod])
|
||||
symbols = []
|
||||
table = span(blob, tags[0x61000039], tags[0x6100003f])
|
||||
for pos in range(0, len(table), 24):
|
||||
n, info, other, section, value, size = unpack('<IBBHQQ', table, pos)
|
||||
name = string(n)
|
||||
symbols.append(dict(name=name, type=info & 15, binding=info >> 4, section=section,
|
||||
value=value, size=size, identity=identity(name) if name else None))
|
||||
relocations = []
|
||||
for ot, st in ((0x61000029, 0x6100002d), (0x6100002f, 0x61000031)):
|
||||
table = span(blob, tags[ot], tags[st])
|
||||
for pos in range(0, len(table), 24):
|
||||
target, info, addend = unpack('<QQq', table, pos)
|
||||
relocations.append((target, info & 0xffffffff, info >> 32, addend))
|
||||
return dict(elf=elf, header=header, ph=ph, tags=tags, symbols=symbols,
|
||||
relocs=relocations, identity=identity, libraries=libraries, modules=modules,
|
||||
sha256=hashlib.sha256(source).hexdigest(), missing=missing)
|
||||
|
||||
|
||||
def link(game, out):
|
||||
main_eboot = game / 'eboot.bin' if (game / 'eboot.bin').is_file() else game / 'eboot.elf'
|
||||
boot_libc = out / 'boot-libc.bin'
|
||||
libc_json = out / 'libc-link.json'
|
||||
boot_bin = out / 'boot.bin'
|
||||
libc_prx = game / 'sce_module/libc.prx'
|
||||
if boot_libc.is_file() and libc_json.is_file() and boot_bin.is_file() and libc_prx.is_file():
|
||||
m = boot_libc.stat().st_mtime
|
||||
if m >= boot_bin.stat().st_mtime and m >= libc_prx.stat().st_mtime and m >= Path(__file__).stat().st_mtime:
|
||||
try:
|
||||
rep = json.loads(libc_json.read_text(encoding='utf-8', errors='replace'))
|
||||
base = int(rep['base'], 16)
|
||||
bindings = rep.get('bindings', 0)
|
||||
tls_memsz = rep.get('tls_memory_bytes', 0)
|
||||
main_tls = rep.get('main_tls', {})
|
||||
eboot_tls = main_tls.get('memsz', 0)
|
||||
patched = rep.get('fs_loads_patched', 0)
|
||||
print(f'Linked native libc: base={base:#x}, {bindings} fallback exports, TLS={tls_memsz} bytes; '
|
||||
f'eboot TLS={eboot_tls} bytes, fs->gs patched={patched}')
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
main = module(main_eboot)
|
||||
libc = module(game/'sce_module/libc.prx')
|
||||
raw = (out/'boot.bin').read_bytes()
|
||||
magic, size, entry, ns, nr, ni, flags = unpack('<8s6Q', raw, 0)
|
||||
if magic != b'BBPROBE2':
|
||||
raise ValueError('linker expects freshly prepared BBPROBE2')
|
||||
pos = 56
|
||||
segments = [unpack('<3Q', raw, pos+i*24) for i in range(ns)]
|
||||
pos += ns*24
|
||||
names = [span(raw, pos+i*128, 128).split(b'\0',1)[0].decode() for i in range(ni)]
|
||||
pos += ni*128
|
||||
relocs = [unpack('<QQqq', raw, pos+i*32) for i in range(nr)]
|
||||
pos += nr*32
|
||||
image = bytearray(span(raw, pos, size))
|
||||
if pos+size != len(raw):
|
||||
raise ValueError('unexpected boot trailer')
|
||||
base = (size+65535) & ~65535
|
||||
loads = [p for p in libc['ph'] if p['type'] in (1,0x61000010)]
|
||||
libsize = max(p['vaddr']+p['memsz'] for p in loads)
|
||||
if base+libsize > 512*1024*1024:
|
||||
raise ValueError('linked image exceeds probe limit')
|
||||
image.extend(bytes(base+libsize-len(image)))
|
||||
def mapped(address, size=8):
|
||||
return any(p['vaddr'] <= address and address+size <= p['vaddr']+p['memsz'] for p in loads)
|
||||
for p in loads:
|
||||
if p['filesz'] > p['memsz']:
|
||||
raise ValueError('invalid libc segment size')
|
||||
image[base+p['vaddr']:base+p['vaddr']+p['filesz']] = span(libc['elf'],p['offset'],p['filesz'])
|
||||
segments.append((base+p['vaddr'],p['memsz'],p['flags']))
|
||||
# Preserve main-program spelling for the existing host resolver. New kernel
|
||||
# NIDs get the main module's spelling only if identity AND version match.
|
||||
identities = {main['identity'](name):i for i,name in enumerate(names)}
|
||||
main_libraries = {identity:key for key,identity in main['libraries'].items()}
|
||||
main_modules = {identity:key for key,identity in main['modules'].items()}
|
||||
def imported(symbol):
|
||||
key = symbol['identity']
|
||||
if key not in identities:
|
||||
n,lib,mod = key
|
||||
name = (f'{n}#{main_libraries[lib]}#{main_modules[mod]}'
|
||||
if lib in main_libraries and mod in main_modules else f'libc:{symbol["name"]}')
|
||||
identities[key] = len(names)
|
||||
names.append(name)
|
||||
return identities[key]
|
||||
tls_count = 0
|
||||
for target, kind, symid, addend in libc['relocs']:
|
||||
if not mapped(target):
|
||||
raise ValueError(f'libc relocation outside image {target:#x}')
|
||||
target += base
|
||||
if kind == 8:
|
||||
relocs.append((target,0,base+addend,0))
|
||||
elif kind == 16:
|
||||
# libc is module 2 in this probe. Descriptor offset is already stored
|
||||
# in the adjacent word; __tls_get_addr will use a separate TLS block.
|
||||
symbol = libc['symbols'][symid]
|
||||
if symbol['name'] or symbol['type'] != 3 or addend:
|
||||
raise ValueError('unsupported external TLS relocation')
|
||||
struct.pack_into('<Q',image,target,2)
|
||||
tls_count += 1
|
||||
elif kind in (1,6,7):
|
||||
symbol = libc['symbols'][symid]
|
||||
if symbol['section']:
|
||||
if symbol['section'] == 0xfff1:
|
||||
struct.pack_into('<Q',image,target,symbol['value']+addend)
|
||||
else:
|
||||
relocs.append((target,0,base+symbol['value']+addend,0))
|
||||
else:
|
||||
rk = 2 if symbol['type']==1 else 1
|
||||
if (rk==1 and addend) or not 0 <= addend < 4096:
|
||||
raise ValueError('unsupported libc import addend')
|
||||
relocs.append((target,rk,imported(symbol),addend))
|
||||
else:
|
||||
raise ValueError(f'unsupported libc relocation type {kind}')
|
||||
exports = {}
|
||||
for s in libc['symbols']:
|
||||
if s['section'] and s['binding'] in (1,2) and s['identity'] and s['type'] in (1,2):
|
||||
if not mapped(s['value'],max(1,s['size'])):
|
||||
raise ValueError('export outside libc load segments')
|
||||
if s['identity'] in exports:
|
||||
raise ValueError('ambiguous libc export')
|
||||
exports[s['identity']] = s
|
||||
bindings = []
|
||||
for identity,index in identities.items():
|
||||
if identity in exports:
|
||||
s = exports[identity]
|
||||
bindings.append((index,base+s['value'],2 if s['type']==1 else 1))
|
||||
# The eboot reads its thread pointer with `mov rax, fs:[0]` (initial-exec
|
||||
# TLS). glibc owns FS on Linux, so rewrite the segment prefix to GS; the
|
||||
# runtime points GS at each guest thread's TCB. Only executable eboot
|
||||
# segments are scanned, and only this exact 9-byte instruction.
|
||||
fs_load = bytes.fromhex('64488b042500000000')
|
||||
patched = 0
|
||||
if sys.platform != 'win32':
|
||||
for p in main['ph']:
|
||||
if p['type'] != 1 or not p['flags'] & 1:
|
||||
continue
|
||||
start, end = p['vaddr'], p['vaddr'] + p['filesz']
|
||||
at = image.find(fs_load, start, end)
|
||||
while at >= 0:
|
||||
image[at] = 0x65
|
||||
patched += 1
|
||||
at = image.find(fs_load, at + len(fs_load), end)
|
||||
main_tls = next((p for p in main['ph'] if p['type']==7), None)
|
||||
main_tls_values = (main_tls['vaddr'], main_tls['filesz'], main_tls['memsz'], main_tls['align']) if main_tls else (0,0,0,0)
|
||||
if main_tls and (main_tls['filesz'] > main_tls['memsz'] or main_tls['memsz'] > 1024*1024
|
||||
or main_tls['vaddr'] + main_tls['filesz'] > size):
|
||||
raise ValueError('unsupported eboot TLS layout')
|
||||
tls = next(p for p in libc['ph'] if p['type']==7)
|
||||
if tls['filesz'] > tls['memsz'] or not mapped(tls['vaddr'],tls['memsz']):
|
||||
raise ValueError('unsupported TLS layout')
|
||||
procparam = next(p for p in main['ph'] if p['type']==0x61000001)
|
||||
metadata = (base,libsize,base+libc['tags'][12],base+tls['vaddr'],tls['memsz'],tls['filesz'],len(bindings),procparam['vaddr'])
|
||||
with (out/'boot-libc.bin').open('wb') as f:
|
||||
f.write(struct.pack('<8s6Q',b'BBPROBE4',len(image),entry,len(segments),len(relocs),len(names),flags))
|
||||
f.write(struct.pack('<8Q',*metadata))
|
||||
f.write(struct.pack('<4Q',*main_tls_values))
|
||||
for binding in bindings: f.write(struct.pack('<3Q',*binding))
|
||||
for segment in segments: f.write(struct.pack('<3Q',*segment))
|
||||
for name in names:
|
||||
if len(name.encode())>=128: raise ValueError('name too long')
|
||||
f.write(name.encode().ljust(128,b'\0'))
|
||||
for relocation in relocs: f.write(struct.pack('<QQqq',*relocation))
|
||||
f.write(image)
|
||||
report = dict(base=hex(base),size=libsize,sha256=libc['sha256'],init=hex(metadata[2]),
|
||||
bindings=len(bindings),tls_module_id=2,fs_loads_patched=patched,
|
||||
main_tls=dict(zip(('vaddr','filesz','memsz','align'),main_tls_values)),tls_relocations=tls_count,
|
||||
tls_template_bytes=tls['filesz'],tls_memory_bytes=tls['memsz'],
|
||||
imports=names,relocation_counts=dict(collections.Counter(r[1] for r in libc['relocs'])),
|
||||
symbol_bindings=[dict(import_name=names[i],address=hex(a),kind=k) for i,a,k in bindings])
|
||||
(out/'libc-link.json').write_text(json.dumps(report,indent=2)+'\n')
|
||||
print(f'Linked native libc: base={base:#x}, {len(bindings)} fallback exports, TLS={tls["memsz"]} bytes; '
|
||||
f'eboot TLS={main_tls_values[2]} bytes, fs->gs patched={patched}')
|
||||
|
||||
|
||||
if __name__=='__main__':
|
||||
import argparse
|
||||
p=argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument('game',type=Path);p.add_argument('--out',type=Path,default=Path(__file__).resolve().parent.parent/'out')
|
||||
a=p.parse_args()
|
||||
link(a.game,a.out)
|
||||
+275
-256
@@ -1,256 +1,275 @@
|
||||
"""Link the eboot with the game's bundled system modules into one probe image.
|
||||
|
||||
Successor of link_libc.py for several modules (default: libc.prx, then
|
||||
libSceFios2.prx). Every module is placed after the previous one; imports of
|
||||
all modules share one import table, so the C runtime can override any
|
||||
function (host contracts first) and otherwise binds the native export.
|
||||
|
||||
Exports are matched by (NID, library identity, module identity). Imports of
|
||||
libSceLibcInternal are served by libc.prx's same-NID exports: the internal
|
||||
library is not shipped with the game and exposes the same functions.
|
||||
Output: out/boot-linked.bin (format BBPROBE5) and out/link.json.
|
||||
"""
|
||||
import collections
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import struct
|
||||
import sys
|
||||
from prepare import parse_self, span, unpack
|
||||
from link_libc import encode_id
|
||||
|
||||
DEFAULT_MODULES = ('libc.prx', 'libSceFios2.prx')
|
||||
FS_LOAD = bytes.fromhex('64488b042500000000') # mov rax, fs:[0]
|
||||
|
||||
|
||||
def module(path):
|
||||
source = path.read_bytes()
|
||||
elf, header, ph, _, missing = parse_self(source)
|
||||
dp = next(p for p in ph if p['type'] == 2)
|
||||
dynamic = []
|
||||
for pos in range(dp['offset'], dp['offset'] + dp['filesz'], 16):
|
||||
tag, value = unpack('<QQ', elf, pos)
|
||||
if not tag:
|
||||
break
|
||||
dynamic.append((tag, value))
|
||||
tags = dict(dynamic)
|
||||
lp = next(p for p in ph if p['type'] == 0x61000000)
|
||||
blob = span(elf, lp['offset'], lp['filesz'])
|
||||
strings = span(blob, tags[0x61000035], tags[0x61000037])
|
||||
|
||||
def string(offset):
|
||||
span(strings, offset, 1)
|
||||
return strings[offset:strings.index(0, offset)].decode('ascii')
|
||||
libraries, modules = {}, {}
|
||||
for tag, value in dynamic:
|
||||
if tag in (0x61000013, 0x61000015, 0x6100000d, 0x6100000f):
|
||||
table = libraries if tag in (0x61000013, 0x61000015) else modules
|
||||
key = encode_id(value >> 48)
|
||||
identity = (string(value & 0xffffffff), (value >> 32) & 65535)
|
||||
if key in table and table[key] != identity:
|
||||
raise ValueError('conflicting module/library IDs')
|
||||
table[key] = identity
|
||||
|
||||
def identity(name):
|
||||
parts = name.split('#')
|
||||
if len(parts) != 3:
|
||||
return None # plain names such as module_start are not linkable
|
||||
n, lib, mod = parts
|
||||
return (n, libraries[lib], modules[mod])
|
||||
symbols = []
|
||||
table = span(blob, tags[0x61000039], tags[0x6100003f])
|
||||
for pos in range(0, len(table), 24):
|
||||
n, info, other, section, value, size = unpack('<IBBHQQ', table, pos)
|
||||
name = string(n)
|
||||
symbols.append(dict(name=name, type=info & 15, binding=info >> 4, section=section,
|
||||
value=value, size=size, identity=identity(name) if name else None))
|
||||
relocations = []
|
||||
for ot, st in ((0x61000029, 0x6100002d), (0x6100002f, 0x61000031)):
|
||||
table = span(blob, tags[ot], tags[st])
|
||||
for pos in range(0, len(table), 24):
|
||||
target, info, addend = unpack('<QQq', table, pos)
|
||||
relocations.append((target, info & 0xffffffff, info >> 32, addend))
|
||||
return dict(elf=elf, header=header, ph=ph, tags=tags, symbols=symbols, relocs=relocations,
|
||||
identity=identity, libraries=libraries, modules=modules,
|
||||
sha256=hashlib.sha256(source).hexdigest(), missing=missing)
|
||||
|
||||
|
||||
def patch_fs_loads(image, ph, base):
|
||||
"""Rewrite initial-exec `mov rax, fs:[0]` to GS: glibc owns FS on Linux."""
|
||||
if sys.platform == 'win32':
|
||||
return 0
|
||||
patched = 0
|
||||
for p in ph:
|
||||
if p['type'] != 1 or not p['flags'] & 1:
|
||||
continue
|
||||
start, end = base + p['vaddr'], base + p['vaddr'] + p['filesz']
|
||||
at = image.find(FS_LOAD, start, end)
|
||||
while at >= 0:
|
||||
image[at] = 0x65
|
||||
patched += 1
|
||||
at = image.find(FS_LOAD, at + len(FS_LOAD), end)
|
||||
return patched
|
||||
|
||||
|
||||
def link(game, out, module_names=DEFAULT_MODULES):
|
||||
main = module(game / 'eboot.bin')
|
||||
raw = (out / 'boot.bin').read_bytes()
|
||||
magic, size, entry, ns, nr, ni, flags = unpack('<8s6Q', raw, 0)
|
||||
if magic != b'BBPROBE2':
|
||||
raise ValueError('linker expects freshly prepared BBPROBE2')
|
||||
pos = 56
|
||||
segments = [unpack('<3Q', raw, pos + i * 24) for i in range(ns)]
|
||||
pos += ns * 24
|
||||
names = [span(raw, pos + i * 128, 128).split(b'\0', 1)[0].decode() for i in range(ni)]
|
||||
pos += ni * 128
|
||||
relocs = [unpack('<QQqq', raw, pos + i * 32) for i in range(nr)]
|
||||
pos += nr * 32
|
||||
image = bytearray(span(raw, pos, size))
|
||||
if pos + size != len(raw):
|
||||
raise ValueError('unexpected boot trailer')
|
||||
|
||||
identities = {main['identity'](name): i for i, name in enumerate(names)}
|
||||
main_libraries = {identity: key for key, identity in main['libraries'].items()}
|
||||
main_modules = {identity: key for key, identity in main['modules'].items()}
|
||||
|
||||
def imported(symbol):
|
||||
key = symbol['identity']
|
||||
if key not in identities:
|
||||
n, lib, mod = key
|
||||
# Main-program spelling when the identity matches (existing host
|
||||
# tables use it); otherwise NID#library for new libraries.
|
||||
name = (f'{n}#{main_libraries[lib]}#{main_modules[mod]}'
|
||||
if lib in main_libraries and mod in main_modules else f'{n}#{lib[0]}')
|
||||
identities[key] = len(names)
|
||||
names.append(name)
|
||||
return identities[key]
|
||||
|
||||
exports, by_nid = {}, collections.defaultdict(list)
|
||||
table = []
|
||||
base = (size + 65535) & ~65535
|
||||
fs_patched = patch_fs_loads(image, main['ph'], 0)
|
||||
tls_module = 2
|
||||
for filename in module_names:
|
||||
m = module(game / 'sce_module' / filename)
|
||||
loads = [p for p in m['ph'] if p['type'] in (1, 0x61000010)]
|
||||
modsize = max(p['vaddr'] + p['memsz'] for p in loads)
|
||||
if base + modsize > 512 * 1024 * 1024:
|
||||
raise ValueError('linked image exceeds probe limit')
|
||||
image.extend(bytes(base + modsize - len(image)))
|
||||
|
||||
def mapped(address, length=8, loads=loads):
|
||||
return any(p['vaddr'] <= address and address + length <= p['vaddr'] + p['memsz'] for p in loads)
|
||||
for p in loads:
|
||||
if p['filesz'] > p['memsz']:
|
||||
raise ValueError(f'{filename}: invalid segment size')
|
||||
image[base + p['vaddr']:base + p['vaddr'] + p['filesz']] = span(m['elf'], p['offset'], p['filesz'])
|
||||
segments.append((base + p['vaddr'], p['memsz'], p['flags']))
|
||||
tls = next((p for p in m['ph'] if p['type'] == 7), None)
|
||||
module_id = 0
|
||||
if tls:
|
||||
if tls['filesz'] > tls['memsz'] or not mapped(tls['vaddr'], tls['memsz']):
|
||||
raise ValueError(f'{filename}: unsupported TLS layout')
|
||||
module_id = tls_module
|
||||
tls_module += 1
|
||||
tls_relocations = 0
|
||||
for target, kind, symid, addend in m['relocs']:
|
||||
if not mapped(target):
|
||||
raise ValueError(f'{filename}: relocation outside image {target:#x}')
|
||||
target += base
|
||||
if kind == 8:
|
||||
relocs.append((target, 0, base + addend, 0))
|
||||
elif kind == 16:
|
||||
symbol = m['symbols'][symid]
|
||||
if symbol['name'] or symbol['type'] != 3 or addend or not module_id:
|
||||
raise ValueError(f'{filename}: unsupported external TLS relocation')
|
||||
struct.pack_into('<Q', image, target, module_id)
|
||||
tls_relocations += 1
|
||||
elif kind in (1, 6, 7):
|
||||
symbol = m['symbols'][symid]
|
||||
if symbol['section']:
|
||||
if symbol['section'] == 0xfff1:
|
||||
struct.pack_into('<Q', image, target, symbol['value'] + addend)
|
||||
else:
|
||||
relocs.append((target, 0, base + symbol['value'] + addend, 0))
|
||||
elif not symbol['identity'] and symbol['binding'] == 2:
|
||||
struct.pack_into('<Q', image, target, 0) # weak undefined (module_start/stop)
|
||||
else:
|
||||
rk = 2 if symbol['type'] == 1 else 1
|
||||
if (rk == 1 and addend) or not 0 <= addend < 4096:
|
||||
raise ValueError(f'{filename}: unsupported import addend')
|
||||
relocs.append((target, rk, imported(symbol), addend))
|
||||
else:
|
||||
raise ValueError(f'{filename}: unsupported relocation type {kind}')
|
||||
count = 0
|
||||
for s in m['symbols']:
|
||||
if s['section'] and s['binding'] in (1, 2) and s['identity'] and s['type'] in (1, 2):
|
||||
if not mapped(s['value'], max(1, s['size'])):
|
||||
raise ValueError(f'{filename}: export outside load segments')
|
||||
if s['identity'] in exports:
|
||||
raise ValueError(f'{filename}: ambiguous export')
|
||||
entry_value = (base + s['value'], 2 if s['type'] == 1 else 1, filename)
|
||||
exports[s['identity']] = entry_value
|
||||
by_nid[(s['identity'][0], s['identity'][1][0])].append(entry_value)
|
||||
count += 1
|
||||
fs_patched += patch_fs_loads(image, m['ph'], base)
|
||||
table.append(dict(file=filename, base=base, size=modsize, init=base + m['tags'].get(12, 0),
|
||||
tls_address=base + tls['vaddr'] if tls else 0, tls_memsz=tls['memsz'] if tls else 0,
|
||||
tls_filesz=tls['filesz'] if tls else 0, tls_module=module_id,
|
||||
tls_relocations=tls_relocations, exports=count, sha256=m['sha256']))
|
||||
base = (base + modsize + 65535) & ~65535
|
||||
|
||||
bindings, unresolved = [], []
|
||||
for identity, index in identities.items():
|
||||
found = exports.get(identity)
|
||||
if not found and identity and identity[1][0] == 'libSceLibcInternal':
|
||||
candidates = by_nid.get((identity[0], 'libc'), [])
|
||||
found = candidates[0] if len(candidates) == 1 else None
|
||||
if found:
|
||||
bindings.append((index, found[0], found[1]))
|
||||
else:
|
||||
unresolved.append(names[index])
|
||||
|
||||
main_tls = next((p for p in main['ph'] if p['type'] == 7), None)
|
||||
main_tls_values = (main_tls['vaddr'], main_tls['filesz'], main_tls['memsz'], main_tls['align']) if main_tls else (0, 0, 0, 0)
|
||||
if main_tls and (main_tls['filesz'] > main_tls['memsz'] or main_tls['memsz'] > 1024 * 1024
|
||||
or main_tls['vaddr'] + main_tls['filesz'] > size):
|
||||
raise ValueError('unsupported eboot TLS layout')
|
||||
procparam = next(p for p in main['ph'] if p['type'] == 0x61000001)
|
||||
with (out / 'boot-linked.bin').open('wb') as f:
|
||||
f.write(struct.pack('<8s6Q', b'BBPROBE5', len(image), entry, len(segments), len(relocs), len(names), flags))
|
||||
f.write(struct.pack('<Q', procparam['vaddr']))
|
||||
f.write(struct.pack('<4Q', *main_tls_values))
|
||||
f.write(struct.pack('<Q', len(table)))
|
||||
for t in table:
|
||||
f.write(struct.pack('<7Q', t['base'], t['size'], t['init'], t['tls_address'], t['tls_memsz'],
|
||||
t['tls_filesz'], t['tls_module']))
|
||||
f.write(struct.pack('<Q', len(bindings)))
|
||||
for binding in bindings:
|
||||
f.write(struct.pack('<3Q', *binding))
|
||||
for segment in segments:
|
||||
f.write(struct.pack('<3Q', *segment))
|
||||
for name in names:
|
||||
if len(name.encode()) >= 128:
|
||||
raise ValueError('name too long')
|
||||
f.write(name.encode().ljust(128, b'\0'))
|
||||
for relocation in relocs:
|
||||
f.write(struct.pack('<QQqq', *relocation))
|
||||
f.write(image)
|
||||
report = dict(modules=[{k: (hex(v) if k in ('base', 'init', 'tls_address') else v) for k, v in t.items()} for t in table],
|
||||
bindings=len(bindings), imports=len(names), fs_loads_patched=fs_patched,
|
||||
main_tls=dict(zip(('vaddr', 'filesz', 'memsz', 'align'), main_tls_values)),
|
||||
unresolved_imports=unresolved)
|
||||
(out / 'link.json').write_text(json.dumps(report, indent=2) + '\n')
|
||||
summary = ', '.join(f"{t['file']}@{t['base']:#x}" for t in table)
|
||||
print(f'Linked modules: {summary}; {len(bindings)} native bindings, {len(unresolved)} imports left to the host runtime, '
|
||||
f'fs->gs patched={fs_patched}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import argparse
|
||||
p = argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument('game', type=Path)
|
||||
p.add_argument('--out', type=Path, default=Path(__file__).resolve().parent.parent / 'out')
|
||||
p.add_argument('--modules', nargs='*', default=list(DEFAULT_MODULES))
|
||||
a = p.parse_args()
|
||||
link(a.game, a.out, a.modules)
|
||||
"""Link the eboot with the game's bundled system modules into one probe image.
|
||||
|
||||
Successor of link_libc.py for several modules (default: libc.prx, then
|
||||
libSceFios2.prx). Every module is placed after the previous one; imports of
|
||||
all modules share one import table, so the C runtime can override any
|
||||
function (host contracts first) and otherwise binds the native export.
|
||||
|
||||
Exports are matched by (NID, library identity, module identity). Imports of
|
||||
libSceLibcInternal are served by libc.prx's same-NID exports: the internal
|
||||
library is not shipped with the game and exposes the same functions.
|
||||
Output: out/boot-linked.bin (format BBPROBE5) and out/link.json.
|
||||
"""
|
||||
import collections
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import struct
|
||||
import sys
|
||||
from prepare import parse_self, span, unpack
|
||||
from link_libc import encode_id
|
||||
|
||||
DEFAULT_MODULES = ('libc.prx', 'libSceFios2.prx')
|
||||
FS_LOAD = bytes.fromhex('64488b042500000000') # mov rax, fs:[0]
|
||||
|
||||
|
||||
def module(path):
|
||||
source = path.read_bytes()
|
||||
elf, header, ph, _, missing = parse_self(source)
|
||||
dp = next(p for p in ph if p['type'] == 2)
|
||||
dynamic = []
|
||||
for pos in range(dp['offset'], dp['offset'] + dp['filesz'], 16):
|
||||
tag, value = unpack('<QQ', elf, pos)
|
||||
if not tag:
|
||||
break
|
||||
dynamic.append((tag, value))
|
||||
tags = dict(dynamic)
|
||||
lp = next(p for p in ph if p['type'] == 0x61000000)
|
||||
blob = span(elf, lp['offset'], lp['filesz'])
|
||||
strings = span(blob, tags[0x61000035], tags[0x61000037])
|
||||
|
||||
def string(offset):
|
||||
span(strings, offset, 1)
|
||||
return strings[offset:strings.index(0, offset)].decode('ascii')
|
||||
libraries, modules = {}, {}
|
||||
for tag, value in dynamic:
|
||||
if tag in (0x61000013, 0x61000015, 0x6100000d, 0x6100000f):
|
||||
table = libraries if tag in (0x61000013, 0x61000015) else modules
|
||||
key = encode_id(value >> 48)
|
||||
identity = (string(value & 0xffffffff), (value >> 32) & 65535)
|
||||
if key in table and table[key] != identity:
|
||||
raise ValueError('conflicting module/library IDs')
|
||||
table[key] = identity
|
||||
|
||||
def identity(name):
|
||||
parts = name.split('#')
|
||||
if len(parts) != 3:
|
||||
return None # plain names such as module_start are not linkable
|
||||
n, lib, mod = parts
|
||||
return (n, libraries[lib], modules[mod])
|
||||
symbols = []
|
||||
table = span(blob, tags[0x61000039], tags[0x6100003f])
|
||||
for pos in range(0, len(table), 24):
|
||||
n, info, other, section, value, size = unpack('<IBBHQQ', table, pos)
|
||||
name = string(n)
|
||||
symbols.append(dict(name=name, type=info & 15, binding=info >> 4, section=section,
|
||||
value=value, size=size, identity=identity(name) if name else None))
|
||||
relocations = []
|
||||
for ot, st in ((0x61000029, 0x6100002d), (0x6100002f, 0x61000031)):
|
||||
table = span(blob, tags[ot], tags[st])
|
||||
for pos in range(0, len(table), 24):
|
||||
target, info, addend = unpack('<QQq', table, pos)
|
||||
relocations.append((target, info & 0xffffffff, info >> 32, addend))
|
||||
return dict(elf=elf, header=header, ph=ph, tags=tags, symbols=symbols, relocs=relocations,
|
||||
identity=identity, libraries=libraries, modules=modules,
|
||||
sha256=hashlib.sha256(source).hexdigest(), missing=missing)
|
||||
|
||||
|
||||
def patch_fs_loads(image, ph, base):
|
||||
"""Rewrite initial-exec `mov rax, fs:[0]` to GS: glibc owns FS on Linux."""
|
||||
if sys.platform == 'win32':
|
||||
return 0
|
||||
patched = 0
|
||||
for p in ph:
|
||||
if p['type'] != 1 or not p['flags'] & 1:
|
||||
continue
|
||||
start, end = base + p['vaddr'], base + p['vaddr'] + p['filesz']
|
||||
at = image.find(FS_LOAD, start, end)
|
||||
while at >= 0:
|
||||
image[at] = 0x65
|
||||
patched += 1
|
||||
at = image.find(FS_LOAD, at + len(FS_LOAD), end)
|
||||
return patched
|
||||
|
||||
|
||||
def link(game, out, module_names=DEFAULT_MODULES):
|
||||
main_eboot = game / 'eboot.bin' if (game / 'eboot.bin').is_file() else game / 'eboot.elf'
|
||||
boot_linked = out / 'boot-linked.bin'
|
||||
link_json = out / 'link.json'
|
||||
boot_bin = out / 'boot.bin'
|
||||
if boot_linked.is_file() and link_json.is_file() and boot_bin.is_file():
|
||||
m = boot_linked.stat().st_mtime
|
||||
if m >= boot_bin.stat().st_mtime and m >= Path(__file__).stat().st_mtime:
|
||||
try:
|
||||
rep = json.loads(link_json.read_text(encoding='utf-8', errors='replace'))
|
||||
table = rep.get('modules', [])
|
||||
summary = ', '.join(f"{t.get('file', 'mod')}@{t.get('base', '0')}" for t in table)
|
||||
bindings = rep.get('bindings', 0)
|
||||
unresolved = len(rep.get('unresolved_imports', []))
|
||||
fs_patched = rep.get('fs_loads_patched', 0)
|
||||
print(f'Linked modules: {summary}; {bindings} native bindings, {unresolved} imports left to the host runtime, '
|
||||
f'fs->gs patched={fs_patched}')
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
main = module(main_eboot)
|
||||
raw = (out / 'boot.bin').read_bytes()
|
||||
magic, size, entry, ns, nr, ni, flags = unpack('<8s6Q', raw, 0)
|
||||
if magic != b'BBPROBE2':
|
||||
raise ValueError('linker expects freshly prepared BBPROBE2')
|
||||
pos = 56
|
||||
segments = [unpack('<3Q', raw, pos + i * 24) for i in range(ns)]
|
||||
pos += ns * 24
|
||||
names = [span(raw, pos + i * 128, 128).split(b'\0', 1)[0].decode() for i in range(ni)]
|
||||
pos += ni * 128
|
||||
relocs = [unpack('<QQqq', raw, pos + i * 32) for i in range(nr)]
|
||||
pos += nr * 32
|
||||
image = bytearray(span(raw, pos, size))
|
||||
if pos + size != len(raw):
|
||||
raise ValueError('unexpected boot trailer')
|
||||
|
||||
identities = {main['identity'](name): i for i, name in enumerate(names)}
|
||||
main_libraries = {identity: key for key, identity in main['libraries'].items()}
|
||||
main_modules = {identity: key for key, identity in main['modules'].items()}
|
||||
|
||||
def imported(symbol):
|
||||
key = symbol['identity']
|
||||
if key not in identities:
|
||||
n, lib, mod = key
|
||||
# Main-program spelling when the identity matches (existing host
|
||||
# tables use it); otherwise NID#library for new libraries.
|
||||
name = (f'{n}#{main_libraries[lib]}#{main_modules[mod]}'
|
||||
if lib in main_libraries and mod in main_modules else f'{n}#{lib[0]}')
|
||||
identities[key] = len(names)
|
||||
names.append(name)
|
||||
return identities[key]
|
||||
|
||||
exports, by_nid = {}, collections.defaultdict(list)
|
||||
table = []
|
||||
base = (size + 65535) & ~65535
|
||||
fs_patched = patch_fs_loads(image, main['ph'], 0)
|
||||
tls_module = 2
|
||||
for filename in module_names:
|
||||
m = module(game / 'sce_module' / filename)
|
||||
loads = [p for p in m['ph'] if p['type'] in (1, 0x61000010)]
|
||||
modsize = max(p['vaddr'] + p['memsz'] for p in loads)
|
||||
if base + modsize > 512 * 1024 * 1024:
|
||||
raise ValueError('linked image exceeds probe limit')
|
||||
image.extend(bytes(base + modsize - len(image)))
|
||||
|
||||
def mapped(address, length=8, loads=loads):
|
||||
return any(p['vaddr'] <= address and address + length <= p['vaddr'] + p['memsz'] for p in loads)
|
||||
for p in loads:
|
||||
if p['filesz'] > p['memsz']:
|
||||
raise ValueError(f'{filename}: invalid segment size')
|
||||
image[base + p['vaddr']:base + p['vaddr'] + p['filesz']] = span(m['elf'], p['offset'], p['filesz'])
|
||||
segments.append((base + p['vaddr'], p['memsz'], p['flags']))
|
||||
tls = next((p for p in m['ph'] if p['type'] == 7), None)
|
||||
module_id = 0
|
||||
if tls:
|
||||
if tls['filesz'] > tls['memsz'] or not mapped(tls['vaddr'], tls['memsz']):
|
||||
raise ValueError(f'{filename}: unsupported TLS layout')
|
||||
module_id = tls_module
|
||||
tls_module += 1
|
||||
tls_relocations = 0
|
||||
for target, kind, symid, addend in m['relocs']:
|
||||
if not mapped(target):
|
||||
raise ValueError(f'{filename}: relocation outside image {target:#x}')
|
||||
target += base
|
||||
if kind == 8:
|
||||
relocs.append((target, 0, base + addend, 0))
|
||||
elif kind == 16:
|
||||
symbol = m['symbols'][symid]
|
||||
if symbol['name'] or symbol['type'] != 3 or addend or not module_id:
|
||||
raise ValueError(f'{filename}: unsupported external TLS relocation')
|
||||
struct.pack_into('<Q', image, target, module_id)
|
||||
tls_relocations += 1
|
||||
elif kind in (1, 6, 7):
|
||||
symbol = m['symbols'][symid]
|
||||
if symbol['section']:
|
||||
if symbol['section'] == 0xfff1:
|
||||
struct.pack_into('<Q', image, target, symbol['value'] + addend)
|
||||
else:
|
||||
relocs.append((target, 0, base + symbol['value'] + addend, 0))
|
||||
elif not symbol['identity'] and symbol['binding'] == 2:
|
||||
struct.pack_into('<Q', image, target, 0) # weak undefined (module_start/stop)
|
||||
else:
|
||||
rk = 2 if symbol['type'] == 1 else 1
|
||||
if (rk == 1 and addend) or not 0 <= addend < 4096:
|
||||
raise ValueError(f'{filename}: unsupported import addend')
|
||||
relocs.append((target, rk, imported(symbol), addend))
|
||||
else:
|
||||
raise ValueError(f'{filename}: unsupported relocation type {kind}')
|
||||
count = 0
|
||||
for s in m['symbols']:
|
||||
if s['section'] and s['binding'] in (1, 2) and s['identity'] and s['type'] in (1, 2):
|
||||
if not mapped(s['value'], max(1, s['size'])):
|
||||
raise ValueError(f'{filename}: export outside load segments')
|
||||
if s['identity'] in exports:
|
||||
raise ValueError(f'{filename}: ambiguous export')
|
||||
entry_value = (base + s['value'], 2 if s['type'] == 1 else 1, filename)
|
||||
exports[s['identity']] = entry_value
|
||||
by_nid[(s['identity'][0], s['identity'][1][0])].append(entry_value)
|
||||
count += 1
|
||||
fs_patched += patch_fs_loads(image, m['ph'], base)
|
||||
table.append(dict(file=filename, base=base, size=modsize, init=base + m['tags'].get(12, 0),
|
||||
tls_address=base + tls['vaddr'] if tls else 0, tls_memsz=tls['memsz'] if tls else 0,
|
||||
tls_filesz=tls['filesz'] if tls else 0, tls_module=module_id,
|
||||
tls_relocations=tls_relocations, exports=count, sha256=m['sha256']))
|
||||
base = (base + modsize + 65535) & ~65535
|
||||
|
||||
bindings, unresolved = [], []
|
||||
for identity, index in identities.items():
|
||||
found = exports.get(identity)
|
||||
if not found and identity and identity[1][0] == 'libSceLibcInternal':
|
||||
candidates = by_nid.get((identity[0], 'libc'), [])
|
||||
found = candidates[0] if len(candidates) == 1 else None
|
||||
if found:
|
||||
bindings.append((index, found[0], found[1]))
|
||||
else:
|
||||
unresolved.append(names[index])
|
||||
|
||||
main_tls = next((p for p in main['ph'] if p['type'] == 7), None)
|
||||
main_tls_values = (main_tls['vaddr'], main_tls['filesz'], main_tls['memsz'], main_tls['align']) if main_tls else (0, 0, 0, 0)
|
||||
if main_tls and (main_tls['filesz'] > main_tls['memsz'] or main_tls['memsz'] > 1024 * 1024
|
||||
or main_tls['vaddr'] + main_tls['filesz'] > size):
|
||||
raise ValueError('unsupported eboot TLS layout')
|
||||
procparam = next(p for p in main['ph'] if p['type'] == 0x61000001)
|
||||
with (out / 'boot-linked.bin').open('wb') as f:
|
||||
f.write(struct.pack('<8s6Q', b'BBPROBE5', len(image), entry, len(segments), len(relocs), len(names), flags))
|
||||
f.write(struct.pack('<Q', procparam['vaddr']))
|
||||
f.write(struct.pack('<4Q', *main_tls_values))
|
||||
f.write(struct.pack('<Q', len(table)))
|
||||
for t in table:
|
||||
f.write(struct.pack('<7Q', t['base'], t['size'], t['init'], t['tls_address'], t['tls_memsz'],
|
||||
t['tls_filesz'], t['tls_module']))
|
||||
f.write(struct.pack('<Q', len(bindings)))
|
||||
for binding in bindings:
|
||||
f.write(struct.pack('<3Q', *binding))
|
||||
for segment in segments:
|
||||
f.write(struct.pack('<3Q', *segment))
|
||||
for name in names:
|
||||
if len(name.encode()) >= 128:
|
||||
raise ValueError('name too long')
|
||||
f.write(name.encode().ljust(128, b'\0'))
|
||||
for relocation in relocs:
|
||||
f.write(struct.pack('<QQqq', *relocation))
|
||||
f.write(image)
|
||||
report = dict(modules=[{k: (hex(v) if k in ('base', 'init', 'tls_address') else v) for k, v in t.items()} for t in table],
|
||||
bindings=len(bindings), imports=len(names), fs_loads_patched=fs_patched,
|
||||
main_tls=dict(zip(('vaddr', 'filesz', 'memsz', 'align'), main_tls_values)),
|
||||
unresolved_imports=unresolved)
|
||||
(out / 'link.json').write_text(json.dumps(report, indent=2) + '\n')
|
||||
summary = ', '.join(f"{t['file']}@{t['base']:#x}" for t in table)
|
||||
print(f'Linked modules: {summary}; {len(bindings)} native bindings, {len(unresolved)} imports left to the host runtime, '
|
||||
f'fs->gs patched={fs_patched}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import argparse
|
||||
p = argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument('game', type=Path)
|
||||
p.add_argument('--out', type=Path, default=Path(__file__).resolve().parent.parent / 'out')
|
||||
p.add_argument('--modules', nargs='*', default=list(DEFAULT_MODULES))
|
||||
a = p.parse_args()
|
||||
link(a.game, a.out, a.modules)
|
||||
+210
-210
@@ -1,210 +1,210 @@
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
"""Merge loose-file mods through links, preserving the original game and mod files."""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
# Top-level folders of the game's dvdroot_ps4: a mod made of these is a dvdroot_ps4 itself.
|
||||
GAME_FOLDERS = {'action', 'chr', 'event', 'facegen', 'font', 'map', 'menu', 'movie', 'msg', 'mtd',
|
||||
'obj', 'other', 'param', 'paramdef', 'parts', 'remo', 'script', 'sfx', 'shader',
|
||||
'sound'}
|
||||
|
||||
|
||||
def child(folder, name):
|
||||
"""`name` inside `folder`, matching an existing entry case-insensitively (mods made on
|
||||
Windows often differ from the game's lower-case names)."""
|
||||
folder = Path(folder)
|
||||
for entry in (folder.iterdir() if folder.is_dir() else ()):
|
||||
if entry.name.casefold() == name.casefold():
|
||||
return entry
|
||||
return folder / name
|
||||
|
||||
|
||||
def content_root(folder):
|
||||
"""(root, prefix): where a mod's files are and the game path they go to, or None.
|
||||
|
||||
Accepted layouts: <mod>/dvdroot_ps4, <mod>/app0/dvdroot_ps4, <mod>/CUSA03173/dvdroot_ps4,
|
||||
one wrapper folder around any of these (an archive extracted into a folder of its name), and
|
||||
the game's folders without dvdroot_ps4 (<mod>/chr, <mod>/parts, ...)."""
|
||||
folder = Path(folder)
|
||||
if not folder.is_dir():
|
||||
return None
|
||||
for wrapper in ('', 'app0', 'CUSA03173'):
|
||||
base = child(folder, wrapper) if wrapper else folder
|
||||
dvdroot = child(base, 'dvdroot_ps4')
|
||||
if dvdroot.is_dir():
|
||||
return base, ''
|
||||
entries = [e for e in folder.iterdir() if not e.name.startswith('.')]
|
||||
folders = [e for e in entries if e.is_dir()]
|
||||
if folders and all(e.name.casefold() in GAME_FOLDERS for e in folders):
|
||||
return folder, 'dvdroot_ps4'
|
||||
if len(folders) == 1 and not [e for e in entries if e.is_file() and
|
||||
e.suffix.casefold() not in ('.txt', '.md', '.jpg', '.png', '.ini')]:
|
||||
return content_root(folders[0])
|
||||
return None
|
||||
|
||||
|
||||
def discover(root):
|
||||
"""Named mods: folders of `root` with an accepted layout (content_root)."""
|
||||
root = Path(root)
|
||||
if not root.is_dir():
|
||||
return []
|
||||
return sorted((p.name for p in root.iterdir() if p.is_dir() and content_root(p)),
|
||||
key=lambda name: (name.casefold(), name))
|
||||
|
||||
|
||||
def selected(root, config):
|
||||
available = discover(root)
|
||||
if not config or not Path(config).is_file():
|
||||
return available
|
||||
settings = json.loads(Path(config).read_text())
|
||||
disabled_names = settings.get('disabled', [])
|
||||
if not isinstance(disabled_names, list) or not all(isinstance(n, str) for n in disabled_names):
|
||||
raise ValueError('Disabled mods must be a list of folder names')
|
||||
disabled = set(disabled_names)
|
||||
order = settings.get('order', [])
|
||||
if not isinstance(order, list) or not all(isinstance(n, str) for n in order):
|
||||
raise ValueError('Mod order must be a list of folder names')
|
||||
# New folders are enabled automatically and appended in alphabetical order.
|
||||
return list(dict.fromkeys(n for n in [*order, *available]
|
||||
if n in available and n not in disabled))
|
||||
|
||||
|
||||
def mod_files(folder):
|
||||
"""(game path, source) of every file a mod replaces or adds under dvdroot_ps4."""
|
||||
layout = content_root(folder)
|
||||
if not layout:
|
||||
raise ValueError(f'{folder}: expected dvdroot_ps4 (or chr/, parts/, ...) inside the mod folder')
|
||||
root, prefix = layout
|
||||
root = root.resolve()
|
||||
for directory, folders, files in os.walk(root, followlinks=False):
|
||||
directory = Path(directory)
|
||||
for name in [*folders, *files]:
|
||||
if (directory / name).is_symlink():
|
||||
raise ValueError(f'Mod symlinks are unsupported: {directory / name}')
|
||||
for name in sorted(files):
|
||||
source = directory / name
|
||||
relative = Path(prefix) / source.relative_to(root) if prefix else source.relative_to(root)
|
||||
# Readme/metadata stay outside the mounted game. This loader handles assets;
|
||||
# executable patches use the existing patch compiler, with address validation.
|
||||
if relative.parts[0].casefold() != 'dvdroot_ps4':
|
||||
if relative.parts[0].casefold() in ('eboot.bin', 'sce_module', 'sce_sys'):
|
||||
raise ValueError(f'Executable/system replacement is unsupported: {source}')
|
||||
continue
|
||||
if not source.is_file():
|
||||
raise ValueError(f'Not a regular mod file: {source}')
|
||||
yield relative, source
|
||||
|
||||
|
||||
def make_link(link_path, target_path, target_is_directory=False):
|
||||
try:
|
||||
link_path.symlink_to(target_path, target_is_directory=target_is_directory)
|
||||
except OSError:
|
||||
if sys.platform == 'win32':
|
||||
if target_is_directory:
|
||||
import _winapi
|
||||
_winapi.CreateJunction(str(target_path), str(link_path))
|
||||
else:
|
||||
try:
|
||||
os.link(str(target_path), str(link_path))
|
||||
except OSError:
|
||||
shutil.copy2(str(target_path), str(link_path))
|
||||
else:
|
||||
raise
|
||||
|
||||
|
||||
def expand(directory):
|
||||
"""Materialize one directory level; never write through a directory link."""
|
||||
if directory.is_symlink():
|
||||
target = directory.resolve(strict=True)
|
||||
if not target.is_dir():
|
||||
raise ValueError(f'File/directory conflict at {directory.name}')
|
||||
directory.unlink()
|
||||
directory.mkdir()
|
||||
for entry in target.iterdir():
|
||||
make_link(directory / entry.name, entry, target_is_directory=entry.is_dir())
|
||||
elif directory.exists() and not directory.is_dir():
|
||||
raise ValueError(f'File/directory conflict at {directory.name}')
|
||||
else:
|
||||
directory.mkdir(exist_ok=True)
|
||||
|
||||
|
||||
def build_overlay(game, out, mods):
|
||||
game = Path(game).resolve(strict=True)
|
||||
replacements = []
|
||||
owners = {}
|
||||
for name, root in mods:
|
||||
count = 0
|
||||
for relative, source in mod_files(root):
|
||||
key = relative.as_posix().casefold()
|
||||
if key in owners:
|
||||
print(f'Mods: {relative}: {owners[key]} -> {name}', file=sys.stderr)
|
||||
owners[key] = name
|
||||
replacements.append((relative, source))
|
||||
count += 1
|
||||
print(f'Mods: {name}: {count} files', file=sys.stderr)
|
||||
if not replacements:
|
||||
return game
|
||||
out = Path(out).resolve()
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
overlay = Path(tempfile.mkdtemp(prefix='mod-game-', dir=out))
|
||||
try:
|
||||
for entry in game.iterdir():
|
||||
make_link(overlay / entry.name, entry, target_is_directory=entry.is_dir())
|
||||
replaced = added = 0
|
||||
for relative, source in replacements:
|
||||
# Each component takes the game's spelling when it exists in another case.
|
||||
parent = overlay
|
||||
for part in relative.parts[:-1]:
|
||||
parent = child(parent, part)
|
||||
expand(parent)
|
||||
destination = child(parent, relative.parts[-1])
|
||||
if destination.is_symlink():
|
||||
replaced += destination.resolve().is_relative_to(game)
|
||||
destination.unlink()
|
||||
elif destination.exists():
|
||||
raise ValueError(f'File/directory conflict: {relative}')
|
||||
else:
|
||||
added += 1
|
||||
make_link(destination, source)
|
||||
print(f'Mods: {replaced} game files replaced, {added} added', file=sys.stderr)
|
||||
return overlay
|
||||
except BaseException:
|
||||
shutil.rmtree(overlay)
|
||||
raise
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('game', type=Path)
|
||||
parser.add_argument('--out', required=True, type=Path)
|
||||
parser.add_argument('--mods-dir', required=True, type=Path)
|
||||
parser.add_argument('--config', type=Path)
|
||||
parser.add_argument('--enabled', choices=('0', '1'), default='1')
|
||||
args = parser.parse_args()
|
||||
layers = []
|
||||
if args.enabled == '1':
|
||||
# shadPS4's loose overlay convention, beside the original game.
|
||||
legacy = Path(str(args.game.resolve()) + '-mods')
|
||||
if legacy.is_dir():
|
||||
layers.append((legacy.name, legacy))
|
||||
# A directly selected loose overlay is also accepted.
|
||||
if child(args.mods_dir, 'dvdroot_ps4').is_dir():
|
||||
layers.append((args.mods_dir.name, args.mods_dir))
|
||||
else:
|
||||
for name in selected(args.mods_dir, args.config):
|
||||
layers.append((name, args.mods_dir / name))
|
||||
print(build_overlay(args.game, args.out, layers))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
main()
|
||||
except (OSError, ValueError, TypeError) as error:
|
||||
print(f'Mods: {error}', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
"""Merge loose-file mods through links, preserving the original game and mod files."""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
# Top-level folders of the game's dvdroot_ps4: a mod made of these is a dvdroot_ps4 itself.
|
||||
GAME_FOLDERS = {'action', 'chr', 'event', 'facegen', 'font', 'map', 'menu', 'movie', 'msg', 'mtd',
|
||||
'obj', 'other', 'param', 'paramdef', 'parts', 'remo', 'script', 'sfx', 'shader',
|
||||
'sound'}
|
||||
|
||||
|
||||
def child(folder, name):
|
||||
"""`name` inside `folder`, matching an existing entry case-insensitively (mods made on
|
||||
Windows often differ from the game's lower-case names)."""
|
||||
folder = Path(folder)
|
||||
for entry in (folder.iterdir() if folder.is_dir() else ()):
|
||||
if entry.name.casefold() == name.casefold():
|
||||
return entry
|
||||
return folder / name
|
||||
|
||||
|
||||
def content_root(folder):
|
||||
"""(root, prefix): where a mod's files are and the game path they go to, or None.
|
||||
|
||||
Accepted layouts: <mod>/dvdroot_ps4, <mod>/app0/dvdroot_ps4, <mod>/CUSA03173/dvdroot_ps4,
|
||||
one wrapper folder around any of these (an archive extracted into a folder of its name), and
|
||||
the game's folders without dvdroot_ps4 (<mod>/chr, <mod>/parts, ...)."""
|
||||
folder = Path(folder)
|
||||
if not folder.is_dir():
|
||||
return None
|
||||
for wrapper in ('', 'app0', 'CUSA03173'):
|
||||
base = child(folder, wrapper) if wrapper else folder
|
||||
dvdroot = child(base, 'dvdroot_ps4')
|
||||
if dvdroot.is_dir():
|
||||
return base, ''
|
||||
entries = [e for e in folder.iterdir() if not e.name.startswith('.')]
|
||||
folders = [e for e in entries if e.is_dir()]
|
||||
if folders and all(e.name.casefold() in GAME_FOLDERS for e in folders):
|
||||
return folder, 'dvdroot_ps4'
|
||||
if len(folders) == 1 and not [e for e in entries if e.is_file() and
|
||||
e.suffix.casefold() not in ('.txt', '.md', '.jpg', '.png', '.ini')]:
|
||||
return content_root(folders[0])
|
||||
return None
|
||||
|
||||
|
||||
def discover(root):
|
||||
"""Named mods: folders of `root` with an accepted layout (content_root)."""
|
||||
root = Path(root)
|
||||
if not root.is_dir():
|
||||
return []
|
||||
return sorted((p.name for p in root.iterdir() if p.is_dir() and content_root(p)),
|
||||
key=lambda name: (name.casefold(), name))
|
||||
|
||||
|
||||
def selected(root, config):
|
||||
available = discover(root)
|
||||
if not config or not Path(config).is_file():
|
||||
return available
|
||||
settings = json.loads(Path(config).read_text())
|
||||
disabled_names = settings.get('disabled', [])
|
||||
if not isinstance(disabled_names, list) or not all(isinstance(n, str) for n in disabled_names):
|
||||
raise ValueError('Disabled mods must be a list of folder names')
|
||||
disabled = set(disabled_names)
|
||||
order = settings.get('order', [])
|
||||
if not isinstance(order, list) or not all(isinstance(n, str) for n in order):
|
||||
raise ValueError('Mod order must be a list of folder names')
|
||||
# New folders are enabled automatically and appended in alphabetical order.
|
||||
return list(dict.fromkeys(n for n in [*order, *available]
|
||||
if n in available and n not in disabled))
|
||||
|
||||
|
||||
def mod_files(folder):
|
||||
"""(game path, source) of every file a mod replaces or adds under dvdroot_ps4."""
|
||||
layout = content_root(folder)
|
||||
if not layout:
|
||||
raise ValueError(f'{folder}: expected dvdroot_ps4 (or chr/, parts/, ...) inside the mod folder')
|
||||
root, prefix = layout
|
||||
root = root.resolve()
|
||||
for directory, folders, files in os.walk(root, followlinks=False):
|
||||
directory = Path(directory)
|
||||
for name in [*folders, *files]:
|
||||
if (directory / name).is_symlink():
|
||||
raise ValueError(f'Mod symlinks are unsupported: {directory / name}')
|
||||
for name in sorted(files):
|
||||
source = directory / name
|
||||
relative = Path(prefix) / source.relative_to(root) if prefix else source.relative_to(root)
|
||||
# Readme/metadata stay outside the mounted game. This loader handles assets;
|
||||
# executable patches use the existing patch compiler, with address validation.
|
||||
if relative.parts[0].casefold() != 'dvdroot_ps4':
|
||||
if relative.parts[0].casefold() in ('eboot.bin', 'eboot.elf', 'sce_module', 'sce_sys'):
|
||||
raise ValueError(f'Executable/system replacement is unsupported: {source}')
|
||||
continue
|
||||
if not source.is_file():
|
||||
raise ValueError(f'Not a regular mod file: {source}')
|
||||
yield relative, source
|
||||
|
||||
|
||||
def make_link(link_path, target_path, target_is_directory=False):
|
||||
try:
|
||||
link_path.symlink_to(target_path, target_is_directory=target_is_directory)
|
||||
except OSError:
|
||||
if sys.platform == 'win32':
|
||||
if target_is_directory:
|
||||
import _winapi
|
||||
_winapi.CreateJunction(str(target_path), str(link_path))
|
||||
else:
|
||||
try:
|
||||
os.link(str(target_path), str(link_path))
|
||||
except OSError:
|
||||
shutil.copy2(str(target_path), str(link_path))
|
||||
else:
|
||||
raise
|
||||
|
||||
|
||||
def expand(directory):
|
||||
"""Materialize one directory level; never write through a directory link."""
|
||||
if directory.is_symlink():
|
||||
target = directory.resolve(strict=True)
|
||||
if not target.is_dir():
|
||||
raise ValueError(f'File/directory conflict at {directory.name}')
|
||||
directory.unlink()
|
||||
directory.mkdir()
|
||||
for entry in target.iterdir():
|
||||
make_link(directory / entry.name, entry, target_is_directory=entry.is_dir())
|
||||
elif directory.exists() and not directory.is_dir():
|
||||
raise ValueError(f'File/directory conflict at {directory.name}')
|
||||
else:
|
||||
directory.mkdir(exist_ok=True)
|
||||
|
||||
|
||||
def build_overlay(game, out, mods):
|
||||
game = Path(game).resolve(strict=True)
|
||||
replacements = []
|
||||
owners = {}
|
||||
for name, root in mods:
|
||||
count = 0
|
||||
for relative, source in mod_files(root):
|
||||
key = relative.as_posix().casefold()
|
||||
if key in owners:
|
||||
print(f'Mods: {relative}: {owners[key]} -> {name}', file=sys.stderr)
|
||||
owners[key] = name
|
||||
replacements.append((relative, source))
|
||||
count += 1
|
||||
print(f'Mods: {name}: {count} files', file=sys.stderr)
|
||||
if not replacements:
|
||||
return game
|
||||
out = Path(out).resolve()
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
overlay = Path(tempfile.mkdtemp(prefix='mod-game-', dir=out))
|
||||
try:
|
||||
for entry in game.iterdir():
|
||||
make_link(overlay / entry.name, entry, target_is_directory=entry.is_dir())
|
||||
replaced = added = 0
|
||||
for relative, source in replacements:
|
||||
# Each component takes the game's spelling when it exists in another case.
|
||||
parent = overlay
|
||||
for part in relative.parts[:-1]:
|
||||
parent = child(parent, part)
|
||||
expand(parent)
|
||||
destination = child(parent, relative.parts[-1])
|
||||
if destination.is_symlink():
|
||||
replaced += destination.resolve().is_relative_to(game)
|
||||
destination.unlink()
|
||||
elif destination.exists():
|
||||
raise ValueError(f'File/directory conflict: {relative}')
|
||||
else:
|
||||
added += 1
|
||||
make_link(destination, source)
|
||||
print(f'Mods: {replaced} game files replaced, {added} added', file=sys.stderr)
|
||||
return overlay
|
||||
except BaseException:
|
||||
shutil.rmtree(overlay)
|
||||
raise
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('game', type=Path)
|
||||
parser.add_argument('--out', required=True, type=Path)
|
||||
parser.add_argument('--mods-dir', required=True, type=Path)
|
||||
parser.add_argument('--config', type=Path)
|
||||
parser.add_argument('--enabled', choices=('0', '1'), default='1')
|
||||
args = parser.parse_args()
|
||||
layers = []
|
||||
if args.enabled == '1':
|
||||
# shadPS4's loose overlay convention, beside the original game.
|
||||
legacy = Path(str(args.game.resolve()) + '-mods')
|
||||
if legacy.is_dir():
|
||||
layers.append((legacy.name, legacy))
|
||||
# A directly selected loose overlay is also accepted.
|
||||
if child(args.mods_dir, 'dvdroot_ps4').is_dir():
|
||||
layers.append((args.mods_dir.name, args.mods_dir))
|
||||
else:
|
||||
for name in selected(args.mods_dir, args.config):
|
||||
layers.append((name, args.mods_dir / name))
|
||||
print(build_overlay(args.game, args.out, layers))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
main()
|
||||
except (OSError, ValueError, TypeError) as error:
|
||||
print(f'Mods: {error}', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
+51
-16
@@ -11,6 +11,7 @@ import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import struct
|
||||
import sys
|
||||
|
||||
|
||||
def span(data, offset, size):
|
||||
@@ -26,10 +27,15 @@ def unpack(fmt, data, offset):
|
||||
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)")
|
||||
if span(data, 0, 4) != b'O\x15=\x1d':
|
||||
raise ValueError(f"{name}: expected PS4 SELF header (magic bytes O\\x15=\\x1d)")
|
||||
count, = unpack('<H', data, 24)
|
||||
base = 32 + count * 32
|
||||
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")
|
||||
@@ -41,6 +47,9 @@ def parse_self(data, name="executable binary"):
|
||||
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)
|
||||
@@ -127,16 +136,44 @@ def inspect_libc(path):
|
||||
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 = game / 'eboot.bin'
|
||||
if not eboot_path.is_file():
|
||||
raise FileNotFoundError(f"Missing eboot.bin at '{eboot_path}'. Please check your game directory.")
|
||||
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.bin at '{eboot_path}' is completely empty (0 bytes). Check your game dump or extraction.")
|
||||
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.bin at '{eboot_path}' is truncated ({len(source)} bytes).")
|
||||
elf, header, ph, segments, missing = parse_self(source, name='eboot.bin')
|
||||
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)
|
||||
@@ -237,10 +274,6 @@ def prepare(game, out):
|
||||
(out / 'entry.bin').write_bytes(image[:1024])
|
||||
resources = collections.Counter()
|
||||
total_bytes = 0
|
||||
for path in (game / 'dvdroot_ps4').rglob('*'):
|
||||
if path.is_file():
|
||||
resources[path.relative_to(game / 'dvdroot_ps4').parts[0]] += 1
|
||||
total_bytes += path.stat().st_size
|
||||
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,
|
||||
@@ -251,8 +284,10 @@ def prepare(game, out):
|
||||
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=False, indent=2) + '\n')
|
||||
print(f"{report['sfo'].get('TITLE')} | entry={header[4]:#x} | image={size:,} bytes")
|
||||
(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()}")
|
||||
|
||||
+53
-14
@@ -1400,13 +1400,18 @@ static void copy_guest_string(uintptr_t ptr, char *out, size_t out_sz) {
|
||||
* A stub at the entry records these before the printer decides to print, return silently
|
||||
* (mode 2) or write to address 0 and abort. */
|
||||
static volatile LONG g_assert_hook_calls;
|
||||
static volatile uint64_t g_last_assert_id, g_last_assert_s1, g_last_assert_s2;
|
||||
static volatile uint64_t g_last_assert_id, g_last_assert_s1, g_last_assert_s2, g_last_assert_a4;
|
||||
|
||||
static void guest_assert_hook(uint64_t s1, uint64_t id, uint64_t s2) {
|
||||
/* `a4` is the first variadic integer argument of the guest call (rcx under the SysV ABI, which
|
||||
* the stub loads into the hook's r9). Assert id 0x61 comes from DLFixedAllocator (a failed
|
||||
* allocation): its arguments say which request failed, which the three fixed arguments alone
|
||||
* never showed. */
|
||||
static void guest_assert_hook(uint64_t s1, uint64_t id, uint64_t s2, uint64_t a4) {
|
||||
LONG seen = InterlockedIncrement(&g_assert_hook_calls);
|
||||
g_last_assert_id = id;
|
||||
g_last_assert_s1 = s1;
|
||||
g_last_assert_s2 = s2;
|
||||
g_last_assert_a4 = a4;
|
||||
if (seen > 20) return; /* keep the first 20; the dump always has the latest */
|
||||
char a[256], b[512];
|
||||
copy_guest_string((uintptr_t)s1, a, sizeof(a));
|
||||
@@ -1415,6 +1420,12 @@ static void guest_assert_hook(uint64_t s1, uint64_t id, uint64_t s2) {
|
||||
(unsigned long long)id, a, b, (void *)(uintptr_t)s1, (void *)(uintptr_t)s2,
|
||||
runtime_thread_current() ? runtime_thread_current()->name : "none",
|
||||
(unsigned long)GetCurrentThreadId());
|
||||
if (id == 0x61) {
|
||||
fprintf(stderr,
|
||||
"[GAME ASSERT] id=0x61 (DLFixedAllocator): first variadic argument a4=0x%llx; "
|
||||
"compare with the allocation failures reported in the crash dump\n",
|
||||
(unsigned long long)a4);
|
||||
}
|
||||
if (seen == 20) fprintf(stderr, "[GAME ASSERT] further assertions are recorded in the crash dump only\n");
|
||||
fflush(stderr);
|
||||
}
|
||||
@@ -1429,6 +1440,12 @@ static void guest_assert_write_last(FILE *f) {
|
||||
(unsigned long long)g_last_assert_id, (long)g_assert_hook_calls);
|
||||
fprintf(f, "String 1: \"%s\" [%p]\n", a, (void *)(uintptr_t)g_last_assert_s1);
|
||||
fprintf(f, "String 2: \"%s\" [%p]\n", b, (void *)(uintptr_t)g_last_assert_s2);
|
||||
fprintf(f, "First variadic argument: 0x%llx\n", (unsigned long long)g_last_assert_a4);
|
||||
if (g_last_assert_id == 0x61) {
|
||||
fprintf(f, "Note: assert 0x61 is DLFixedAllocator (allocation failed); the failure "
|
||||
"counters above say whether the guest allocator or the emulated memory layer "
|
||||
"refused the request.\n");
|
||||
}
|
||||
}
|
||||
|
||||
static void dump_loaded_modules(FILE *f) {
|
||||
@@ -2259,8 +2276,6 @@ static void log_redzone_guard_hit(uintptr_t off, uintptr_t rip, uintptr_t rsp, G
|
||||
pos += snprintf(buf + pos, sizeof(buf) - pos, " (RSP invalid or unreadable)\n");
|
||||
}
|
||||
veh_write(buf);
|
||||
fprintf(stderr, "%s", buf);
|
||||
fflush(stderr);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2333,13 +2348,28 @@ static LONG WINAPI win_veh_handler(EXCEPTION_POINTERS *ep) {
|
||||
}
|
||||
/* fall through to the fatal path (no infinite retry) */
|
||||
}
|
||||
if (gpu_enabled && bbgpu_handle_fault(ep, fault_addr)) {
|
||||
const ULONG_PTR access_type =
|
||||
(ep->ExceptionRecord && ep->ExceptionRecord->NumberParameters >= 1) ?
|
||||
ep->ExceptionRecord->ExceptionInformation[0] : 0;
|
||||
/* The PS4 particle/splatter/cloth vertex generators keep locals in the System V red
|
||||
* zone. Windows x64 has none: every exception dispatch writes its CONTEXT below RSP and
|
||||
* destroys them, so each repeated write-tracking fault inside these ranges can corrupt
|
||||
* the generator. Tell the tracker to keep the faulting page writable (bounded, and only
|
||||
* for these routines) instead of re-protecting it after every upload. */
|
||||
const int redzone_fault =
|
||||
access_type == 1 /* write */ && is_guest_address(rip) &&
|
||||
(rip - (uintptr_t)image) >= 0x28ce7ba && (rip - (uintptr_t)image) < 0x28d064e;
|
||||
int fault_handled = 0;
|
||||
if (gpu_enabled) {
|
||||
if (redzone_fault) bbgpu_set_redzone_fault(1);
|
||||
fault_handled = bbgpu_handle_fault(ep, fault_addr);
|
||||
if (redzone_fault) bbgpu_set_redzone_fault(0);
|
||||
}
|
||||
if (fault_handled) {
|
||||
if (cur) cur->exception_count++;
|
||||
return EXCEPTION_CONTINUE_EXECUTION;
|
||||
}
|
||||
// Camera update thunk DEP or unmapped jump safeguard (Discord crash 204614)
|
||||
ULONG_PTR access_type = (ep->ExceptionRecord && ep->ExceptionRecord->NumberParameters >= 1) ?
|
||||
ep->ExceptionRecord->ExceptionInformation[0] : 0;
|
||||
if (access_type == 8 /* EXECUTE violation */ || rip < 0x10000 || rip == (uintptr_t)orig_cam_update) {
|
||||
uintptr_t expected_orig = (uintptr_t)image + 0x143ac60;
|
||||
uintptr_t rsp = ep->ContextRecord->Rsp;
|
||||
@@ -2963,9 +2993,6 @@ int main(int argc, char **argv) {
|
||||
if (argc == 9 && !strcmp(argv[1], "--merge-shader-metas"))
|
||||
return bbgpu_merge_shader_metas(argv[2], argv[3], argv[4], argv[5], argv[6], argv[7], atoi(argv[8]));
|
||||
|
||||
/* Rebuild caches from older compiler versions if needed */
|
||||
bbgpu_rebuild_shader_caches();
|
||||
|
||||
#ifdef _WIN32
|
||||
runtime_memory_reserve_space();
|
||||
int avx_status = runtime_win_check_avx();
|
||||
@@ -3064,7 +3091,11 @@ int main(int argc, char **argv) {
|
||||
if (app0) {
|
||||
runtime_file_configure(app0, user_dir ? user_dir : "user");
|
||||
#ifdef _WIN32
|
||||
SetEnvironmentVariableA("BB_SHADER_GAME_DIR", app0);
|
||||
/* bbport: SetEnvironmentVariableA only updates the Win32 environment block. This
|
||||
* executable is linked with the static CRT, so getenv() (bb_env in bb_env.h) reads a
|
||||
* separate copy and never saw the value: StartShaderImport() could not find the game
|
||||
* directory and never launched the recipe import. _putenv_s updates both. */
|
||||
_putenv_s("BB_SHADER_GAME_DIR", app0);
|
||||
#else
|
||||
setenv("BB_SHADER_GAME_DIR", app0, 0);
|
||||
#endif
|
||||
@@ -3078,7 +3109,8 @@ int main(int argc, char **argv) {
|
||||
if (rf) {
|
||||
fclose(rf);
|
||||
#ifdef _WIN32
|
||||
SetEnvironmentVariableA("BB_SHADER_RECIPES", "shader_recipes.bin");
|
||||
/* bbport: same CRT/Win32 split as BB_SHADER_GAME_DIR above. */
|
||||
_putenv_s("BB_SHADER_RECIPES", "shader_recipes.bin");
|
||||
#else
|
||||
setenv("BB_SHADER_RECIPES", "shader_recipes.bin", 0);
|
||||
#endif
|
||||
@@ -3227,6 +3259,8 @@ int main(int argc, char **argv) {
|
||||
gpu_enabled=1; /* page tracking starts while the rasterizer registers guest memory */
|
||||
if (bbgpu_init(&gpu)) fail("GPU initialization failed");
|
||||
printf("GPU: window and Vulkan presenter ready; SDK 0x%08x, %u HLE symbols\n",(unsigned)sdk,bbgpu_symbol_count());
|
||||
/* Rebuild caches from older compiler versions if needed */
|
||||
bbgpu_rebuild_shader_caches();
|
||||
}
|
||||
image_size = size;
|
||||
resolved_targets = calloc(import_count + 1, sizeof(void *));
|
||||
@@ -3492,8 +3526,13 @@ int main(int argc, char **argv) {
|
||||
*p++ = 0x49; *p++ = 0x89; *p++ = 0xd0; /* mov r8, rdx (string 2) */
|
||||
*p++ = 0x48; *p++ = 0x89; *p++ = 0xf2; /* mov rdx, rsi (assert id) */
|
||||
*p++ = 0x48; *p++ = 0x89; *p++ = 0xf9; /* mov rcx, rdi (string 1) */
|
||||
/* Hook parameters are therefore (rcx=string 1, rdx=assert id, r8=string 2), which
|
||||
* matches guest_assert_hook(uint64_t s1, uint64_t id, uint64_t s2). */
|
||||
/* The guest uses the SysV ABI, so the first variadic integer argument is rcx at the
|
||||
* printer's entry. The stub pushed it above; here rsp is 0xC8 bytes below that slot,
|
||||
* so load it into r9, the hook's fourth parameter. */
|
||||
*p++ = 0x4c; *p++ = 0x8b; *p++ = 0x4c; *p++ = 0x24; *p++ = 0xc8; /* mov r9,[rsp+0xc8] */
|
||||
/* Hook parameters are therefore (rcx=string 1, rdx=assert id, r8=string 2, r9=first
|
||||
* variadic argument), which matches
|
||||
* guest_assert_hook(uint64_t s1, uint64_t id, uint64_t s2, uint64_t a4). */
|
||||
uint64_t hook = (uint64_t)(uintptr_t)guest_assert_hook;
|
||||
*p++ = 0x48; *p++ = 0xb8; memcpy(p, &hook, 8); p += 8; /* movabs rax, hook */
|
||||
*p++ = 0xff; *p++ = 0xd0; /* call rax */
|
||||
|
||||
+158
-10
@@ -66,7 +66,14 @@ static size_t reads;
|
||||
static uint8_t connected_count;
|
||||
|
||||
static uint64_t now_us(void) { return host_monotonic_ns() / 1000u; }
|
||||
static uint8_t axis(int16_t v) { int x=(v+32768)>>8; return (uint8_t)(x<0 ? 0 : x>255 ? 255 : x); }
|
||||
static uint8_t axis(int16_t v) {
|
||||
const int deadzone = 3200;
|
||||
if (v > -deadzone && v < deadzone) return 128;
|
||||
float scaled = (v > 0) ? (float)(v - deadzone) / (32767 - deadzone)
|
||||
: (float)(v + deadzone) / (32768 - deadzone);
|
||||
int x = 128 + (int)lroundf(scaled * (scaled > 0 ? 127.0f : 128.0f));
|
||||
return (uint8_t)(x < 0 ? 0 : x > 255 ? 255 : x);
|
||||
}
|
||||
static uint8_t trigger(int16_t v) { int x=v>>7; return (uint8_t)(x<0 ? 0 : x>255 ? 255 : x); }
|
||||
static uint16_t touch_axis(float v, int max) {
|
||||
return (uint16_t)(v<=0.0f ? 0 : v>=1.0f ? max : (int)(v*max+0.5f));
|
||||
@@ -74,6 +81,131 @@ static uint16_t touch_axis(float v, int max) {
|
||||
static uint8_t synth_touch_id = 1;
|
||||
static uint64_t touch_down_us = 0;
|
||||
|
||||
#ifndef M_PI
|
||||
#define M_PI 3.14159265358979323846
|
||||
#endif
|
||||
|
||||
/* Analog simulation for keyboard movement: smooth acceleration (rise 70ms, fall 45ms),
|
||||
* natural angular sweep on direction changes (1100 deg/s), true unit-circle normalization
|
||||
* so diagonal movement does not overshoot or jerk, straight movements stay dead-center,
|
||||
* and last-input priority (SOCD last-win) on opposing keys so strafing does not dead-stop. */
|
||||
typedef struct {
|
||||
float angle, mag;
|
||||
uint64_t last_us;
|
||||
uint64_t press_seq;
|
||||
uint64_t left_order, right_order, up_order, down_order;
|
||||
uint8_t prev_left, prev_right, prev_up, prev_down;
|
||||
} KeyStick;
|
||||
|
||||
static float key_tunable(const char *name, float def) {
|
||||
const char *env = getenv(name);
|
||||
if (!env || !*env) return def;
|
||||
const float v = (float)atof(env);
|
||||
return v > 0.0f ? v : def;
|
||||
}
|
||||
|
||||
static int key_stick(KeyStick *st, uint64_t now, int left, int right, int up, int down, uint8_t *x, uint8_t *y) {
|
||||
static float rise_ms, fall_ms, turn_dps;
|
||||
if (rise_ms == 0.0f) {
|
||||
rise_ms = key_tunable("BB_KEY_RISE_MS", 70.0f);
|
||||
fall_ms = key_tunable("BB_KEY_FALL_MS", 45.0f);
|
||||
turn_dps = key_tunable("BB_KEY_TURN_DPS", 1100.0f);
|
||||
}
|
||||
|
||||
const int l_press = left && !st->prev_left;
|
||||
const int r_press = right && !st->prev_right;
|
||||
const int u_press = up && !st->prev_up;
|
||||
const int d_press = down && !st->prev_down;
|
||||
|
||||
if (l_press && r_press) {
|
||||
const uint64_t seq = ++st->press_seq;
|
||||
st->left_order = seq;
|
||||
st->right_order = seq;
|
||||
} else if (l_press) {
|
||||
st->left_order = ++st->press_seq;
|
||||
} else if (r_press) {
|
||||
st->right_order = ++st->press_seq;
|
||||
}
|
||||
|
||||
if (u_press && d_press) {
|
||||
const uint64_t seq = ++st->press_seq;
|
||||
st->up_order = seq;
|
||||
st->down_order = seq;
|
||||
} else if (u_press) {
|
||||
st->up_order = ++st->press_seq;
|
||||
} else if (d_press) {
|
||||
st->down_order = ++st->press_seq;
|
||||
}
|
||||
|
||||
st->prev_left = left ? 1 : 0;
|
||||
st->prev_right = right ? 1 : 0;
|
||||
st->prev_up = up ? 1 : 0;
|
||||
st->prev_down = down ? 1 : 0;
|
||||
|
||||
int tx = 0;
|
||||
if (left && right) {
|
||||
if (st->right_order > st->left_order) tx = 1;
|
||||
else if (st->left_order > st->right_order) tx = -1;
|
||||
else tx = 0;
|
||||
} else if (right) {
|
||||
tx = 1;
|
||||
} else if (left) {
|
||||
tx = -1;
|
||||
}
|
||||
|
||||
int ty = 0;
|
||||
if (up && down) {
|
||||
if (st->down_order > st->up_order) ty = 1;
|
||||
else if (st->up_order > st->down_order) ty = -1;
|
||||
else ty = 0;
|
||||
} else if (down) {
|
||||
ty = 1;
|
||||
} else if (up) {
|
||||
ty = -1;
|
||||
}
|
||||
|
||||
float dt = (st->last_us && now > st->last_us) ? (float)(now - st->last_us) * 1e-6f : 0.0f;
|
||||
st->last_us = now;
|
||||
if (dt > 0.05f) dt = 0.05f;
|
||||
const float target_mag = (tx || ty) ? 1.0f : 0.0f;
|
||||
if (tx || ty) {
|
||||
const float target = atan2f((float)ty, (float)tx);
|
||||
if (st->mag < 0.12f) {
|
||||
st->angle = target;
|
||||
} else {
|
||||
float diff = target - st->angle;
|
||||
while (diff > (float)M_PI) diff -= 2.0f * (float)M_PI;
|
||||
while (diff < -(float)M_PI) diff += 2.0f * (float)M_PI;
|
||||
if (fabsf(fabsf(diff) - (float)M_PI) < 0.15f) {
|
||||
st->angle = target;
|
||||
st->mag = 0.0f;
|
||||
} else {
|
||||
const float step = turn_dps * ((float)M_PI / 180.0f) * dt;
|
||||
st->angle += (diff > step) ? step : ((diff < -step) ? -step : diff);
|
||||
}
|
||||
}
|
||||
}
|
||||
const float rate = (target_mag > st->mag) ? (1.0f / (rise_ms * 1e-3f)) : (1.0f / (fall_ms * 1e-3f));
|
||||
const float dm = rate * dt;
|
||||
st->mag += (target_mag > st->mag) ? ((target_mag - st->mag < dm) ? (target_mag - st->mag) : dm)
|
||||
: -((st->mag - target_mag < dm) ? (st->mag - target_mag) : dm);
|
||||
if (st->mag < 0.003f) {
|
||||
st->mag = 0.0f;
|
||||
return 0;
|
||||
}
|
||||
float fx = cosf(st->angle) * st->mag;
|
||||
float fy = sinf(st->angle) * st->mag;
|
||||
if (tx == 0 && ty != 0 && fabsf(fx) < 0.005f) fx = 0.0f;
|
||||
if (ty == 0 && tx != 0 && fabsf(fy) < 0.005f) fy = 0.0f;
|
||||
int vx = 128 + (int)lroundf(fx * 127.0f);
|
||||
int vy = 128 + (int)lroundf(fy * 127.0f);
|
||||
*x = (uint8_t)(vx < 0 ? 0 : vx > 255 ? 255 : vx);
|
||||
*y = (uint8_t)(vy < 0 ? 0 : vy > 255 ? 255 : vy);
|
||||
return 1;
|
||||
}
|
||||
|
||||
static KeyStick move_stick;
|
||||
|
||||
static void touch_click(PadData *d, int right) {
|
||||
uint64_t t = now_us();
|
||||
if (!touch_down_us) {
|
||||
@@ -431,11 +563,23 @@ static void sample_host(PadData *d) {
|
||||
|
||||
// Movement (Left Stick); the walk key holds the stick under the engine's run threshold
|
||||
const int walk = is_action_down(binds[BB_ACTION_WALK], k, lmb, rmb, mmb, x1, x2, chords);
|
||||
const uint8_t stick_lo = walk ? 128 - 48 : 0, stick_hi = walk ? 128 + 48 : 255;
|
||||
if (is_action_down(binds[BB_ACTION_MOVE_LEFT], k, lmb, rmb, mmb, x1, x2, chords)) d->left_x = stick_lo;
|
||||
else if (is_action_down(binds[BB_ACTION_MOVE_RIGHT], k, lmb, rmb, mmb, x1, x2, chords)) d->left_x = stick_hi;
|
||||
if (is_action_down(binds[BB_ACTION_MOVE_FORWARD], k, lmb, rmb, mmb, x1, x2, chords)) d->left_y = stick_lo;
|
||||
else if (is_action_down(binds[BB_ACTION_MOVE_BACKWARD], k, lmb, rmb, mmb, x1, x2, chords)) d->left_y = stick_hi;
|
||||
const int move_l = is_action_down(binds[BB_ACTION_MOVE_LEFT], k, lmb, rmb, mmb, x1, x2, chords);
|
||||
const int move_r = is_action_down(binds[BB_ACTION_MOVE_RIGHT], k, lmb, rmb, mmb, x1, x2, chords);
|
||||
const int move_u = is_action_down(binds[BB_ACTION_MOVE_FORWARD], k, lmb, rmb, mmb, x1, x2, chords);
|
||||
const int move_d = is_action_down(binds[BB_ACTION_MOVE_BACKWARD], k, lmb, rmb, mmb, x1, x2, chords);
|
||||
if (key_stick(&move_stick, d->timestamp, move_l, move_r, move_u, move_d, &d->left_x, &d->left_y)) {
|
||||
if (walk) {
|
||||
d->left_x = (uint8_t)(128 + ((int)d->left_x - 128) * 2 / 5);
|
||||
d->left_y = (uint8_t)(128 + ((int)d->left_y - 128) * 2 / 5);
|
||||
}
|
||||
} else if (walk) {
|
||||
d->left_x = (uint8_t)(128 + ((int)d->left_x - 128) * 2 / 5);
|
||||
d->left_y = (uint8_t)(128 + ((int)d->left_y - 128) * 2 / 5);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// Mouse as right stick whenever the native camera hook doesn't own it (lock-on target switching):
|
||||
// each delta is an impulse that decays over LOOK_TAU_US.
|
||||
@@ -473,10 +617,14 @@ static void sample_host(PadData *d) {
|
||||
for (size_t i=0;i<sizeof(keys)/sizeof(*keys);++i) if (k[keys[i].key]) d->buttons|=keys[i].ps;
|
||||
if (k[SDL_SCANCODE_TAB]) touch_click(d,0);
|
||||
if (k[SDL_SCANCODE_BACKSPACE]) touch_click(d,1);
|
||||
if (k[SDL_SCANCODE_A]) d->left_x = 0;
|
||||
else if (k[SDL_SCANCODE_D]) d->left_x = 255;
|
||||
if (k[SDL_SCANCODE_W]) d->left_y = 0;
|
||||
else if (k[SDL_SCANCODE_S]) d->left_y = 255;
|
||||
const int key_l = k && k[SDL_SCANCODE_A];
|
||||
const int key_r = k && k[SDL_SCANCODE_D];
|
||||
const int key_u = k && k[SDL_SCANCODE_W];
|
||||
const int key_d = k && k[SDL_SCANCODE_S];
|
||||
key_stick(&move_stick, d->timestamp, key_l, key_r, key_u, key_d, &d->left_x, &d->left_y);
|
||||
|
||||
|
||||
|
||||
|
||||
if (k[SDL_SCANCODE_I]) d->right_y = 0;
|
||||
else if (k[SDL_SCANCODE_K]) d->right_y = 255;
|
||||
|
||||
+98
-1
@@ -6,6 +6,7 @@
|
||||
* Metadata lives beside the directory so the guest's own files are untouched. */
|
||||
#define _GNU_SOURCE
|
||||
#include "runtime.h"
|
||||
#include "gpu/bbgpu.h"
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
@@ -239,11 +240,107 @@ static int slot_of(const MountPoint *point) {
|
||||
return end && !*end && n>=0 && n<SLOTS && slots[n].used ? (int)n : -1;
|
||||
}
|
||||
|
||||
static void runtime_savedata_safepoint_check(const char *host, const char *meta) {
|
||||
if (!host || !*host) return;
|
||||
const char *env = getenv("BB_SAVE_AUTO_BACKUP");
|
||||
if (env && env[0] == '0') return;
|
||||
if (!bbgpu_get_save_auto_backup()) return;
|
||||
|
||||
/* Check if userdata0010 exists in the mounted host directory */
|
||||
char u10[800];
|
||||
snprintf(u10, sizeof(u10), "%s/userdata0010", host);
|
||||
struct stat st;
|
||||
if (stat(u10, &st) != 0 || st.st_size < 0x2000) return;
|
||||
|
||||
/* Sum playtimes across all character slots (stride 0x192 from 0x1080) to gauge character progress */
|
||||
FILE *f = fopen(u10, "rb");
|
||||
if (!f) return;
|
||||
uint32_t playtime = 0;
|
||||
for (int s = 0; s < 10; ++s) {
|
||||
uint32_t p = 0;
|
||||
if (fseek(f, 0x1080 + s * 0x192 + 0x3C, SEEK_SET) == 0) {
|
||||
if (fread(&p, sizeof(p), 1, f) == 1 && p > 0 && p < 10000000) {
|
||||
playtime += p;
|
||||
}
|
||||
}
|
||||
}
|
||||
fclose(f);
|
||||
|
||||
/* Throttle: only create an auto-safepoint if at least 30s of gameplay progressed
|
||||
* or at least 180s have passed since the last safepoint snapshot */
|
||||
static uint32_t s_last_safepoint_playtime = 0;
|
||||
static uint64_t s_last_safepoint_time = 0;
|
||||
uint64_t now = (uint64_t)time(NULL);
|
||||
if (s_last_safepoint_playtime && playtime <= s_last_safepoint_playtime + 30 && (now - s_last_safepoint_time) < 180) {
|
||||
return;
|
||||
}
|
||||
s_last_safepoint_playtime = playtime;
|
||||
s_last_safepoint_time = now;
|
||||
|
||||
/* Backup destination: <user_dir>/savedata_backups/auto_safepoint/SPRJ0005 */
|
||||
char backup_root[800], backup_dir[850], backup_meta[850], meta_json[850];
|
||||
snprintf(backup_root, sizeof(backup_root), "%s/savedata_backups", runtime_file_user_dir());
|
||||
snprintf(backup_dir, sizeof(backup_dir), "%s/auto_safepoint/SPRJ0005", backup_root);
|
||||
snprintf(backup_meta, sizeof(backup_meta), "%s/auto_safepoint/SPRJ0005.sce_sys", backup_root);
|
||||
snprintf(meta_json, sizeof(meta_json), "%s/auto_safepoint/meta.json", backup_root);
|
||||
make_dirs(backup_dir);
|
||||
if (meta && *meta) make_dirs(backup_meta);
|
||||
|
||||
DIR *d = opendir(host);
|
||||
if (d) {
|
||||
for (struct dirent *e; (e = readdir(d));) {
|
||||
if (e->d_name[0] == '.') continue;
|
||||
char src_file[1024], dst_file[1024];
|
||||
snprintf(src_file, sizeof(src_file), "%s/%s", host, e->d_name);
|
||||
snprintf(dst_file, sizeof(dst_file), "%s/%s", backup_dir, e->d_name);
|
||||
#ifdef _WIN32
|
||||
CopyFileA(src_file, dst_file, FALSE);
|
||||
#endif
|
||||
}
|
||||
closedir(d);
|
||||
}
|
||||
if (meta && *meta) {
|
||||
DIR *dm = opendir(meta);
|
||||
if (dm) {
|
||||
for (struct dirent *e; (e = readdir(dm));) {
|
||||
if (e->d_name[0] == '.') continue;
|
||||
char src_file[1024], dst_file[1024];
|
||||
snprintf(src_file, sizeof(src_file), "%s/%s", meta, e->d_name);
|
||||
snprintf(dst_file, sizeof(dst_file), "%s/%s", backup_meta, e->d_name);
|
||||
#ifdef _WIN32
|
||||
CopyFileA(src_file, dst_file, FALSE);
|
||||
#endif
|
||||
}
|
||||
closedir(dm);
|
||||
}
|
||||
}
|
||||
|
||||
FILE *fm = fopen(meta_json, "w");
|
||||
if (fm) {
|
||||
time_t t = (time_t)now;
|
||||
struct tm tm_buf;
|
||||
#ifdef _WIN32
|
||||
localtime_s(&tm_buf, &t);
|
||||
#else
|
||||
localtime_r(&t, &tm_buf);
|
||||
#endif
|
||||
char ts[64];
|
||||
strftime(ts, sizeof(ts), "%Y-%m-%d %H:%M:%S", &tm_buf);
|
||||
fprintf(fm, "{\n \"name\": \"Engine Auto-Safepoint\",\n \"backup_type\": \"safepoint\",\n \"timestamp\": \"%s\"\n}\n", ts);
|
||||
fclose(fm);
|
||||
}
|
||||
printf("Runtime: auto safepoint backup updated in '%s'\n", backup_dir);
|
||||
}
|
||||
|
||||
static ABI int32_t save_umount(const MountPoint *point) {
|
||||
if (!initialized) return ERR_NOT_INITIALIZED;
|
||||
host_lock(&lock);
|
||||
int slot=slot_of(point);
|
||||
if (slot>=0) { runtime_file_unmount(point->data); slots[slot].used=0; }
|
||||
if (slot>=0) {
|
||||
runtime_savedata_safepoint_check(slots[slot].host, slots[slot].meta);
|
||||
runtime_file_unmount(point->data);
|
||||
slots[slot].used=0;
|
||||
}
|
||||
host_unlock(&lock);
|
||||
return slot>=0 ? 0 : ERR_NOT_FOUND;
|
||||
}
|
||||
|
||||
+165
-127
@@ -1,128 +1,166 @@
|
||||
#define _GNU_SOURCE
|
||||
#include <assert.h>
|
||||
#include <unistd.h>
|
||||
#ifdef _WIN32
|
||||
#include <windows.h>
|
||||
#include <fcntl.h>
|
||||
#include <stdlib.h>
|
||||
static inline int setenv(const char *name, const char *value, int overwrite) {
|
||||
(void)overwrite;
|
||||
return _putenv_s(name, value);
|
||||
}
|
||||
#define usleep(us) Sleep((us) / 1000)
|
||||
#endif
|
||||
#include "../src/runtime_pad.c"
|
||||
|
||||
static int capture;
|
||||
int bbgpu_overlay_captures_input(void) { return capture; }
|
||||
int bbgpu_text_input_is_active(void) { return 0; }
|
||||
int bbgpu_mouse_captured(void) { return 1; }
|
||||
int bbgpu_menu_pointer_mouse_buttons(void) { return 0; }
|
||||
uint32_t bbgpu_menu_pointer_buttons(uint32_t buttons, uint8_t lx, uint8_t ly) { (void)buttons; (void)lx; (void)ly; return 0; }
|
||||
void bbgpu_consume_mouse_delta(float *dx, float *dy) { if (dx) *dx = 0.0f; if (dy) *dy = 0.0f; }
|
||||
int bbgpu_mouse_active_recent(void) { return 0; }
|
||||
int bbgpu_get_mk_native_mouse(void) { return 1; }
|
||||
int native_camera_owns_mouse(void) { return 1; }
|
||||
int bbgpu_get_mk_config(float *sens_x, float *sens_y, int *invert_x, int *invert_y, float *deadzone, float *smoothing) {
|
||||
if (sens_x) { *sens_x = 1.0f; }
|
||||
if (sens_y) { *sens_y = 1.0f; }
|
||||
if (invert_x) { *invert_x = 0; }
|
||||
if (invert_y) { *invert_y = 0; }
|
||||
if (deadzone) { *deadzone = 0.05f; }
|
||||
if (smoothing) { *smoothing = 0.2f; }
|
||||
return 1;
|
||||
}
|
||||
int bbgpu_get_action_binding(int action) { (void)action; return 0; }
|
||||
void bbgpu_get_all_action_bindings(int *out_bindings, int count) {
|
||||
if (out_bindings) memset(out_bindings, 0, sizeof(int) * count);
|
||||
}
|
||||
void bbgpu_set_action_binding(int action, int code) { (void)action; (void)code; }
|
||||
uint64_t host_monotonic_ns(void) {
|
||||
LARGE_INTEGER freq, counter;
|
||||
QueryPerformanceFrequency(&freq);
|
||||
QueryPerformanceCounter(&counter);
|
||||
return (uint64_t)((counter.QuadPart * 1000000000ULL) / freq.QuadPart);
|
||||
}
|
||||
uintptr_t runtime_lookup(const RuntimeExport *table, size_t count, const char *name) {
|
||||
(void)table; (void)count; (void)name;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void inject(const char *path, const char *tokens) {
|
||||
FILE *f=fopen(path,"w");
|
||||
assert(f);
|
||||
fputs(tokens,f);
|
||||
fclose(f);
|
||||
usleep(25000);
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
#ifdef _WIN32
|
||||
char temp_dir[MAX_PATH];
|
||||
GetTempPathA(sizeof(temp_dir), temp_dir);
|
||||
char path[512];
|
||||
snprintf(path, sizeof(path), "%sbbport-pad-test-XXXXXX", temp_dir);
|
||||
#else
|
||||
char path[]="/tmp/bbport-pad-test-XXXXXX";
|
||||
#endif
|
||||
int fd=mkstemp(path);
|
||||
assert(fd>=0);
|
||||
close(fd);
|
||||
setenv("BB_PAD_FILE",path,1);
|
||||
setenv("SDL_VIDEODRIVER","dummy",1);
|
||||
/* Only the virtual test controller is a gamepad, whatever is plugged in. */
|
||||
SDL_SetHint(SDL_HINT_GAMECONTROLLER_IGNORE_DEVICES_EXCEPT,"0x1d50/0x6189");
|
||||
assert(SDL_Init(SDL_INIT_VIDEO|SDL_INIT_GAMEPAD));
|
||||
assert(pad_init()==0 && pad_open(1,0,0,NULL)==1);
|
||||
PadData data;
|
||||
inject(path,"cross l3 touchpad_left");
|
||||
assert(pad_read_state(1,&data)==0);
|
||||
assert((data.buttons & (BTN_CROSS|BTN_L3|BTN_TOUCHPAD))==(BTN_CROSS|BTN_L3|BTN_TOUCHPAD));
|
||||
assert(data.touch_count==1 && data.touches[0].x==480 && data.touches[0].y==471);
|
||||
inject(path,"touchpad_right");
|
||||
assert(pad_read_state(1,&data)==0 && data.touch_count==1 && data.touches[0].x==1440);
|
||||
inject(path,"");
|
||||
assert(pad_read_state(1,&data)==0 && data.buttons==0 && data.touch_count==0);
|
||||
|
||||
SDL_VirtualJoystickTouchpadDesc touch={.nfingers=2};
|
||||
SDL_VirtualJoystickDesc desc;
|
||||
SDL_INIT_INTERFACE(&desc);
|
||||
desc.type=SDL_JOYSTICK_TYPE_GAMEPAD;
|
||||
desc.naxes=SDL_GAMEPAD_AXIS_COUNT;
|
||||
desc.nbuttons=SDL_GAMEPAD_BUTTON_COUNT;
|
||||
desc.button_mask=(1u<<SDL_GAMEPAD_BUTTON_COUNT)-1;
|
||||
desc.axis_mask=(1u<<SDL_GAMEPAD_AXIS_COUNT)-1;
|
||||
desc.name="bbport test controller";
|
||||
desc.vendor_id=0x1d50;
|
||||
desc.product_id=0x6189;
|
||||
desc.ntouchpads=1;
|
||||
desc.touchpads=&touch;
|
||||
SDL_JoystickID id=SDL_AttachVirtualJoystick(&desc);
|
||||
assert(id!=0);
|
||||
SDL_Joystick *joystick=SDL_OpenJoystick(id);
|
||||
assert(joystick);
|
||||
assert(SDL_SetJoystickVirtualTouchpad(joystick,0,0,true,0.75f,0.5f,1.0f));
|
||||
assert(SDL_SetJoystickVirtualTouchpad(joystick,0,1,true,0.25f,1.0f,1.0f));
|
||||
assert(SDL_SetJoystickVirtualButton(joystick,SDL_GAMEPAD_BUTTON_TOUCHPAD,true));
|
||||
SDL_UpdateJoysticks();
|
||||
SDL_UpdateGamepads();
|
||||
assert(pad_read_state(1,&data)==0);
|
||||
assert(gamepad && data.touch_count==2 && (data.buttons & BTN_TOUCHPAD));
|
||||
assert(data.touches[0].x==1439 && data.touches[0].y==471 && data.touches[0].id==0);
|
||||
assert(data.touches[1].x==480 && data.touches[1].y==942 && data.touches[1].id==1);
|
||||
capture=1;
|
||||
assert(pad_read_state(1,&data)==0 && data.touch_count==0 && data.buttons==0);
|
||||
capture=0;
|
||||
assert(SDL_SetJoystickVirtualTouchpad(joystick,0,0,false,0,0,0));
|
||||
assert(SDL_SetJoystickVirtualTouchpad(joystick,0,1,false,0,0,0));
|
||||
SDL_UpdateJoysticks();
|
||||
SDL_UpdateGamepads();
|
||||
assert(pad_read_state(1,&data)==0 && data.touch_count==1 && data.touches[0].x==480);
|
||||
SDL_CloseJoystick(joystick);
|
||||
if (gamepad) SDL_CloseGamepad(gamepad);
|
||||
gamepad=NULL;
|
||||
assert(SDL_DetachVirtualJoystick(id));
|
||||
SDL_Quit();
|
||||
#define _GNU_SOURCE
|
||||
#include <assert.h>
|
||||
#include <unistd.h>
|
||||
#ifdef _WIN32
|
||||
#include <windows.h>
|
||||
#include <fcntl.h>
|
||||
#include <stdlib.h>
|
||||
static inline int setenv(const char *name, const char *value, int overwrite) {
|
||||
(void)overwrite;
|
||||
return _putenv_s(name, value);
|
||||
}
|
||||
#define usleep(us) Sleep((us) / 1000)
|
||||
#endif
|
||||
#include "../src/runtime_pad.c"
|
||||
|
||||
static int capture;
|
||||
int bbgpu_overlay_captures_input(void) { return capture; }
|
||||
int bbgpu_text_input_is_active(void) { return 0; }
|
||||
int bbgpu_mouse_captured(void) { return 1; }
|
||||
int bbgpu_menu_pointer_mouse_buttons(void) { return 0; }
|
||||
uint32_t bbgpu_menu_pointer_buttons(uint32_t buttons, uint8_t lx, uint8_t ly) { (void)buttons; (void)lx; (void)ly; return 0; }
|
||||
void bbgpu_consume_mouse_delta(float *dx, float *dy) { if (dx) *dx = 0.0f; if (dy) *dy = 0.0f; }
|
||||
int bbgpu_mouse_active_recent(void) { return 0; }
|
||||
int bbgpu_get_mk_native_mouse(void) { return 1; }
|
||||
int native_camera_owns_mouse(void) { return 1; }
|
||||
int bbgpu_get_mk_config(float *sens_x, float *sens_y, int *invert_x, int *invert_y, float *deadzone, float *smoothing) {
|
||||
if (sens_x) { *sens_x = 1.0f; }
|
||||
if (sens_y) { *sens_y = 1.0f; }
|
||||
if (invert_x) { *invert_x = 0; }
|
||||
if (invert_y) { *invert_y = 0; }
|
||||
if (deadzone) { *deadzone = 0.05f; }
|
||||
if (smoothing) { *smoothing = 0.2f; }
|
||||
return 1;
|
||||
}
|
||||
int bbgpu_get_action_binding(int action) { (void)action; return 0; }
|
||||
void bbgpu_get_all_action_bindings(int *out_bindings, int count) {
|
||||
if (out_bindings) memset(out_bindings, 0, sizeof(int) * count);
|
||||
}
|
||||
void bbgpu_set_action_binding(int action, int code) { (void)action; (void)code; }
|
||||
uint64_t host_monotonic_ns(void) {
|
||||
LARGE_INTEGER freq, counter;
|
||||
QueryPerformanceFrequency(&freq);
|
||||
QueryPerformanceCounter(&counter);
|
||||
return (uint64_t)((counter.QuadPart * 1000000000ULL) / freq.QuadPart);
|
||||
}
|
||||
uintptr_t runtime_lookup(const RuntimeExport *table, size_t count, const char *name) {
|
||||
(void)table; (void)count; (void)name;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void inject(const char *path, const char *tokens) {
|
||||
FILE *f=fopen(path,"w");
|
||||
assert(f);
|
||||
fputs(tokens,f);
|
||||
fclose(f);
|
||||
usleep(25000);
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
#ifdef _WIN32
|
||||
char temp_dir[MAX_PATH];
|
||||
GetTempPathA(sizeof(temp_dir), temp_dir);
|
||||
char path[512];
|
||||
snprintf(path, sizeof(path), "%sbbport-pad-test-XXXXXX", temp_dir);
|
||||
#else
|
||||
char path[]="/tmp/bbport-pad-test-XXXXXX";
|
||||
#endif
|
||||
int fd=mkstemp(path);
|
||||
assert(fd>=0);
|
||||
close(fd);
|
||||
setenv("BB_PAD_FILE",path,1);
|
||||
setenv("SDL_VIDEODRIVER","dummy",1);
|
||||
/* Only the virtual test controller is a gamepad, whatever is plugged in. */
|
||||
SDL_SetHint(SDL_HINT_GAMECONTROLLER_IGNORE_DEVICES_EXCEPT,"0x1d50/0x6189");
|
||||
assert(SDL_Init(SDL_INIT_VIDEO|SDL_INIT_GAMEPAD));
|
||||
assert(pad_init()==0 && pad_open(1,0,0,NULL)==1);
|
||||
PadData data;
|
||||
inject(path,"cross l3 touchpad_left");
|
||||
assert(pad_read_state(1,&data)==0);
|
||||
assert((data.buttons & (BTN_CROSS|BTN_L3|BTN_TOUCHPAD))==(BTN_CROSS|BTN_L3|BTN_TOUCHPAD));
|
||||
assert(data.touch_count==1 && data.touches[0].x==480 && data.touches[0].y==471);
|
||||
inject(path,"touchpad_right");
|
||||
assert(pad_read_state(1,&data)==0 && data.touch_count==1 && data.touches[0].x==1440);
|
||||
inject(path,"");
|
||||
assert(pad_read_state(1,&data)==0 && data.buttons==0 && data.touch_count==0);
|
||||
|
||||
SDL_VirtualJoystickTouchpadDesc touch={.nfingers=2};
|
||||
SDL_VirtualJoystickDesc desc;
|
||||
SDL_INIT_INTERFACE(&desc);
|
||||
desc.type=SDL_JOYSTICK_TYPE_GAMEPAD;
|
||||
desc.naxes=SDL_GAMEPAD_AXIS_COUNT;
|
||||
desc.nbuttons=SDL_GAMEPAD_BUTTON_COUNT;
|
||||
desc.button_mask=(1u<<SDL_GAMEPAD_BUTTON_COUNT)-1;
|
||||
desc.axis_mask=(1u<<SDL_GAMEPAD_AXIS_COUNT)-1;
|
||||
desc.name="bbport test controller";
|
||||
desc.vendor_id=0x1d50;
|
||||
desc.product_id=0x6189;
|
||||
desc.ntouchpads=1;
|
||||
desc.touchpads=&touch;
|
||||
SDL_JoystickID id=SDL_AttachVirtualJoystick(&desc);
|
||||
assert(id!=0);
|
||||
SDL_Joystick *joystick=SDL_OpenJoystick(id);
|
||||
assert(joystick);
|
||||
assert(SDL_SetJoystickVirtualTouchpad(joystick,0,0,true,0.75f,0.5f,1.0f));
|
||||
assert(SDL_SetJoystickVirtualTouchpad(joystick,0,1,true,0.25f,1.0f,1.0f));
|
||||
assert(SDL_SetJoystickVirtualButton(joystick,SDL_GAMEPAD_BUTTON_TOUCHPAD,true));
|
||||
SDL_UpdateJoysticks();
|
||||
SDL_UpdateGamepads();
|
||||
assert(pad_read_state(1,&data)==0);
|
||||
assert(gamepad && data.touch_count==2 && (data.buttons & BTN_TOUCHPAD));
|
||||
assert(data.touches[0].x==1439 && data.touches[0].y==471 && data.touches[0].id==0);
|
||||
assert(data.touches[1].x==480 && data.touches[1].y==942 && data.touches[1].id==1);
|
||||
capture=1;
|
||||
assert(pad_read_state(1,&data)==0 && data.touch_count==0 && data.buttons==0);
|
||||
capture=0;
|
||||
assert(SDL_SetJoystickVirtualTouchpad(joystick,0,0,false,0,0,0));
|
||||
assert(SDL_SetJoystickVirtualTouchpad(joystick,0,1,false,0,0,0));
|
||||
SDL_UpdateJoysticks();
|
||||
SDL_UpdateGamepads();
|
||||
assert(pad_read_state(1,&data)==0 && data.touch_count==1 && data.touches[0].x==480);
|
||||
SDL_CloseJoystick(joystick);
|
||||
if (gamepad) SDL_CloseGamepad(gamepad);
|
||||
gamepad=NULL;
|
||||
assert(SDL_DetachVirtualJoystick(id));
|
||||
SDL_Quit();
|
||||
unlink(path);
|
||||
puts("PASS: pad ABI, debug camera chord, left/right clicks, SDL touch coordinates, overlay capture");
|
||||
}
|
||||
|
||||
/* Test key_stick last-input priority (SOCD last-win) */
|
||||
KeyStick ks = {0};
|
||||
uint8_t sx = 128, sy = 128;
|
||||
uint64_t t = 1000000;
|
||||
|
||||
/* 1. Hold Left (A) for 10 frames (160ms) */
|
||||
for (int i = 0; i < 10; ++i) { t += 16000; key_stick(&ks, t, 1, 0, 0, 0, &sx, &sy); }
|
||||
assert(sx < 5 && sy == 128); /* Full Left */
|
||||
|
||||
/* 2. Press Right (D) while still holding Left (A) for 10 frames -> Right was pressed last, so Right wins! */
|
||||
for (int i = 0; i < 10; ++i) { t += 16000; key_stick(&ks, t, 1, 1, 0, 0, &sx, &sy); }
|
||||
assert(sx > 250 && sy == 128); /* Full Right */
|
||||
|
||||
/* 3. Release Right (D) while still holding Left (A) for 10 frames -> Left resumes! */
|
||||
for (int i = 0; i < 10; ++i) { t += 16000; key_stick(&ks, t, 1, 0, 0, 0, &sx, &sy); }
|
||||
assert(sx < 5 && sy == 128); /* Resumed Full Left */
|
||||
|
||||
/* 4. Release all -> centers to 128 */
|
||||
for (int i = 0; i < 10; ++i) { t += 16000; if (!key_stick(&ks, t, 0, 0, 0, 0, &sx, &sy)) { sx = 128; sy = 128; } }
|
||||
assert(sx == 128 && sy == 128);
|
||||
|
||||
/* 5. Vertical: Hold Up (W) for 10 frames */
|
||||
for (int i = 0; i < 10; ++i) { t += 16000; key_stick(&ks, t, 0, 0, 1, 0, &sx, &sy); }
|
||||
assert(sy < 5 && sx == 128); /* Full Up */
|
||||
|
||||
/* 6. Press Down (S) while holding Up (W) -> Down was pressed last, Down wins! */
|
||||
for (int i = 0; i < 10; ++i) { t += 16000; key_stick(&ks, t, 0, 0, 1, 1, &sx, &sy); }
|
||||
assert(sy > 250 && sx == 128); /* Full Down */
|
||||
|
||||
/* 7. Release Down (S) while holding Up (W) -> Up resumes! */
|
||||
for (int i = 0; i < 10; ++i) { t += 16000; key_stick(&ks, t, 0, 0, 1, 0, &sx, &sy); }
|
||||
assert(sy < 5 && sx == 128); /* Resumed Full Up */
|
||||
|
||||
/* 8. Release all -> centers to 128 */
|
||||
for (int i = 0; i < 10; ++i) { t += 16000; if (!key_stick(&ks, t, 0, 0, 0, 0, &sx, &sy)) { sx = 128; sy = 128; } }
|
||||
assert(sx == 128 && sy == 128);
|
||||
|
||||
puts("PASS: pad ABI, debug camera chord, left/right clicks, SDL touch coordinates, overlay capture, key_stick SOCD");
|
||||
}
|
||||
Reference in new issue
Block a user