Compare commits

...
21 Commits
Author SHA1 Message Date
Mrsuss60 b3350bb4f5 Launcher: UI polish, global cursor handler, and lazy tab loading to fix freezes
- Implemented a global `CursorGuard` to automatically apply the pointing-hand cursor to all interactive widgets.
- Fixed clipped Save Manager button text by enforcing stylesheet `min-height` and removing stray padding spaces.
- Eliminated tab-switching stutters by background-loading the M&K, Advanced, and Saves tabs via `QTimer` and deferring I/O scans.
- Added a yellow warning label to patches disabled by the Live FPS conflict check.
- Resolved tab routing logic bugs, removed duplicate `QTimer` calls, and fixed an `IndentationError`.
2026-11-07 00:16:07 +00:00
Mrsuss60 019939a5e5 fix(launcher): resolve inverted AZERTY and QWERTY keybind presets and scancode mappings 2026-11-06 23:03:33 +00:00
Mrsuss60 17bfa1e4b7 style(launcher): add Lvl and Playtime labels to save slot cards 2026-11-06 19:10:47 +00:00
Mrsuss60 1cc0678c21 fix(launcher): embed universal save slot portrait and fix Windows PE/taskbar icons
- Replace player-specific slot crops and distorted icon0.png title banners with a universal 512x512 Bloodborne cover portrait for all save cards
- Update find_character_portrait to resolve assets from PyInstaller's runtime bundle (sys._MEIPASS) and embed resources via build_bblauncher.bat
- Fix batch variable expansion conflict in build_bblauncher.bat that dropped the --icon parameter during PyInstaller builds
- Re-encode icon.ico into standard Windows DIB/BMP format across 7 resolutions (16 to 256) to ensure native Windows Explorer shell support
- Call SetCurrentProcessExplicitAppUserModelID and bind app.setWindowIcon on startup to display the launcher icon on the Windows taskbar
2026-11-06 19:02:37 +00:00
Mrsuss60 432d2ddbf1 gpu, probe: fix permutation conflicts, stale metas, and red-zone faults
- Reuse an equal-spec permutation module found at a different index instead of dropping the preload, recovering the permutation-conflict preloads
- Purge stale-version metas after the preload pass along with their orphaned .spv/.in companions, eliminating missing-file errors and silent key drops
- Migrate the stale profile header in place by accepting binary version 24 and updating it to 25, unifying cache versioning on 25
- Add bounded "sticky writable pages" for known generator RIP ranges to survive Windows KiUserExceptionDispatch clobbering the System V red zone
- Expose the StickyFaultPages toggle and BbStats::sticky_pages counter, with BB_STICKY_FAULT_PAGES env and A/B toggle bit 1<<2
- Set the red-zone fault hint around bbgpu_handle_fault and merge bbgpu.h/bbgpu.cpp with the save-manager changes
2026-11-06 13:22:49 +00:00
Mrsuss60 ce83b806b9 gpu, probe: stop shader cache wipes and recurring pipeline recompilation
- Unifies the cache version constants under PipelineStore so the runtime, the rebuild tool and the recipe importer can no longer stamp different binary versions into profile.bin and wipe the cache on the next launch.
- Accepts a stale 24 version header in both WarmUp and the rebuild tool and rewrites it in place instead of clearing valid shaders, and names the differing profile bytes when a real mismatch forces a rebuild.
- Fixes the imported meta layout to the four fields RegisterShaderMeta actually writes, removing two stray motion addresses that shifted the specialization by 16 bytes and made every imported recipe unreadable.
- Moves the periodic driver pipeline cache dump to a saver thread and flushes it there, taking the creation mutex so the async compiler workers cannot race the VkPipelineCache handle.
- Drains the cache IO queue in Close() before stopping the worker and flushes profile.bin before the import child starts, so queued writes are not lost at exit or race the child's profile validation.
- Records only recipes whose program or reference code is missing in the running game build, keeping transient failures retryable, and switches the launch environment to _putenv_s so the import child receives the game directory.
2026-11-06 02:23:13 +00:00
Mrsuss60 8d76278ccc feat(save): add save game manager, slot parser, and ng+ safepoints (closes #188)
Implement a complete Save Game Manager spanning the PySide6 launcher and the C runtime.

- Launcher UI: Added "SAVE MANAGER" sidebar tab with in-game styled character slot cards (portrait, level, playtime clock, blood echoes, and area/map location name) and backup controls.
- Save Data Parser: Decoded unencrypted userdata0010 slot tables (base 0x1080, stride 0x192), extracting character names (UTF-16LE), levels (+0x38), playtime (+0x3C), blood echoes (+0x40), and map IDs (+0x48) mapped via FromSoftware location tables.
- Save Path Resolution: Implemented param.sfo binary parser reading INSTALL_DIR_SAVEDATA to dynamically route save files to canonical title ID CUSA00207 across all regional and GOTY editions, with automatic legacy directory migration.
- Backup & Safeguard System: Added manual backups, pre-launch rolling snapshots with auto-pruning, pre-restore safety backups, and zip archive import/export bundling companion SPRJ0005.sce_sys metadata.
- C Runtime Safepoint: Hooked save_umount in src/runtime_savedata.c to throttle and auto-snapshot save data to user/savedata_backups/auto_safepoint across all character slots before NG+ transitions. Controlled via bbport.ini setting save_auto_backup and BB_SAVE_AUTO_BACKUP environment override.
2026-11-06 01:52:37 +00:00
Mrsuss60 3e60038643 gpu, probe: fix random exit 139, 140 and 23 crashes in long sessions
- Keeps the data a deferred command reads in the same recording chunk until that chunk has executed, and serializes chunk allocation and hand-off between the GPU and recording threads so a recycled chunk is never read under a live command.
- Compiles graphics and compute pipelines one at a time on the shared VkPipelineCache, and turns creation failures and rejected shaders into logged and skipped draws instead of process aborts.
- Recovers from malformed or desynced PM4 streams by abandoning the submission or skipping the packet, with rate-limited diagnostics in place of the fatal asserts.
- Records the guest allocator assertion (DLFixedAllocator, id 0x61) with its first argument next to the allocation failure counters in the crash dump.

closes #177
closes #178
closes #181
closes #185
and addresses similar issues reported on discord server
2026-11-04 14:16:05 +00:00
Mrsuss60 8ea9aa3744 input: add last-input priority and straight-line reversals to keyboard movement
-Implements last-input priority for opposing directional keys so pressing a new direction while holding the previous one immediately takes precedence instead of stopping to neutral.
-Resumes movement in the original direction automatically when the newer opposing key is released while the first key remains held.
-Ramps direct 180-degree reversals through center magnitude along the target axis rather than sweeping around the outer perimeter to prevent perpendicular drift during quick strafe switches.
-Adds automated unit test coverage in the pad test suite to verify opposing key overrides, release resumptions, and center decays.
2026-11-03 20:12:19 +00:00
MrSuS60 34e567b9e4 Merge pull request #190 from HatsyRei/fix/ui-composition-memo-scale
renderer_vulkan: redirect stencil-only UI mask draws into the UI depth
2026-11-03 10:43:56 +00:00
Mrsuss60 94388d5921 Fix(input): Fix keyboard movement drift and add analog stick simulation
* Eliminates sideways drift during keyboard movement by having movement keys drive both stick axes simultaneously while adding a deadzone to prevent resting gamepad drift from bleeding through.
* Simulates smooth analog stick ramping with 70 ms rise and 45 ms fall times so the character accelerates and decelerates naturally.
* Normalizes diagonal movement vectors along the unit circle to prevent speed overextension and smoothly sweeps direction changes at 1100 degrees per second.
* Automatically cancels opposing directional key combinations to neutral.
2026-11-03 00:15:50 +00:00
Mrsuss60 d9b1bc6e07 launcher: add texture quality setting, reorganize display settings, and fix keyboard layout bindings
* Add a texture quality selector with auto, full, half, and quarter modes to the Display & Graphics tab, persisting choices to bbport.ini and passing them via BB_TEXTURE_QUALITY at launch.
* Reorganize the Display & Graphics tab by grouping upscaler controls into a dedicated Upscaler Settings card and replacing the redundant enable checkbox with direct dropdown selection.
* Fix keybind assignment discrepancies on AZERTY and QWERTY layouts by storing printed letter characters directly so button labels and configuration entries stay synchronized.
* Increments launcher version to 0.8.1 and records the active texture quality mode in the startup log banner.
2026-11-02 22:02:42 +00:00
Mrsuss60 8841db5b24 gpu: port upstream mip-skipping texture quality setting
Implement mip-skipping texture quality setting based on upstream bloodborne_pc

* Adapts the mip-skipping texture quality feature from deadinside's upstream implementation (https://github.com/deadinside28/bloodborne_pc).
* Adds a configurable texture quality setting with auto, full, half, and quarter modes to reduce memory usage on graphics cards with limited VRAM.
* Evaluates the Vulkan device memory budget on startup to automatically select half quality on GPUs with 3 GB of VRAM or less when auto mode is selected.
* Restricts mip reduction strictly to 2D block-compressed surfaces of at least 512x512 with three or more mips, leaving render targets, depth buffers, storage images, and GPU-modified surfaces at full resolution.
* Adjusts staging upload copies, detiling compute dispatches, and descriptor view ranges to ensure shader sampling seamlessly aligns with reduced textures.
* Retains complete guest metadata across image lookups to prevent cache invalidation loops and ensure stable frame pacing.
2026-11-02 21:56:29 +00:00
Mrsuss60 ed73550c85 gpu, scripts: resolve startup stalls via async pipeline warmup and cached pre-launch
Eliminate multi-minute boot delays across pre-window launch and initial black screen presentation:

- Vulkan (vk_pipeline_serialization): Dispatch cached graphics pipelines asynchronously to worker threads via pending_graphics during WarmUp, removing the synchronous driver compilation stall on the render thread before the first frame.
- Vulkan (vk_shader_rebuild): Protect RebuildDirectory on Windows with Vectored Exception Handling to safely bypass incompatible shaders, and persist ShaderBinaryVersion in profile.bin on failure to stop repeating broken rebuilds every launch.
- Vulkan (vk_pipeline_import): Cap startup shader recipe merges to a 5 ms budget, deferring remaining imports to per-frame polls.
- Host Runtime (probe.c): Defer bbgpu_rebuild_shader_caches() until after bbgpu_init() so the SDL window and presenter open immediately.
- Pre-launch Scripts (prepare, link_libc, link_modules): Add mtime checks against source binaries to reuse existing boot images, and remove the redundant 28,000-file traversal in prepare.py.
- Platform (nvidia_profile): Add a single-run guard to BbNvidiaProfile::Apply() to prevent redundant NVAPI driver queries.
2026-11-02 17:47:31 +00:00
Mrsuss60 2054129fae launcher, scripts: support eboot.elf alongside eboot.bin across all launch paths (#189)
Extend executable discovery and validation across the launcher, run.bat, and
preparation scripts to accept both eboot.bin and eboot.elf interchangeably,
following the raw ELF parsing support merged in PR #189.

Changes:
- bblauncherAPP/bblauncher_Utils.py:
  - Added find_eboot_file() to probe for eboot.bin first, falling back to eboot.elf.
  - Updated resolve_game_dir() to accept direct file paths or directories containing
    either eboot.bin or eboot.elf.
  - Updated check_live_clock_signature() and verify_prelaunch() to identify and
    validate whichever executable variant is present.
- bblauncherAPP/bblauncher_UI.py & bblauncher_app.py:
  - Updated Browse file dialog filter to "PS4 Executables (*.bin *.elf);;All Files (*)".
  - Updated path field placeholders, drop-area hints, and error diagnostics to
    reference both eboot.bin and eboot.elf.
- run.bat:
  - Added argument checks and game directory existence verification for eboot.elf.
- scripts (prepare.py, game_check.py, link_libc.py, link_modules.py, mods.py):
  - Added find_eboot() helper to resolve eboot.bin or eboot.elf across preparation,
    image hash validation, and module linking.
  - Protected eboot.elf alongside eboot.bin from being overwritten by mod asset links.

Resolves #189 follow-up where Itemzflow and raw ELF dumps named eboot.elf were
rejected during launcher path resolution.
2026-11-02 15:07:28 +00:00
MrSuS60 d0f2a891b5 Merge pull request #189 from facundo1701/facundo1701-patch-accept-raw-elf
Update prepary.py to accept raw elf
2026-11-02 15:46:53 +01:00
HatsyRei 574f133635 renderer_vulkan: redirect stencil-only UI mask draws into the UI depth
At outputs other than 1080p, the UI-only composition path (no upscaled scene: upscaler off, loading screens, quit to menu) took the UI depth from the trigger draw, often none. Mask draws with color writes off never reached TemporalUpscaler::OnDraw, so they went into the guest depth, and the next icon draw switched the UI depth and cleared its stencil. Icons clipped by those masks (HUD quick item, loading screen item) failed the stencil test and were missing. Stencil-only draws now adopt a UI-sized depth as the UI depth, so the mask is drawn where the icons test against it.
2026-11-02 18:46:50 +08:00
facundo1701 d7b2db1122 Update prepary.py to accept raw elf
Accept an already-unwrapped ELF as eboot.bin

Problem:
Some dumpers (e.g. Itemzflow) write eboot.bin as a plain ELF (7F 45 4C 46)
instead of a plaintext PS4 SELF. prepare.py then exits with:
prepare failed: eboot.bin: expected PS4 SELF header (magic bytes O\x15=\x1d)
and the game cannot start.

Cause:
parse_self() only accepts the SELF magic. It rebuilds an ELF from the SELF's
embedded ELF header, program headers and segment data. A raw ELF already is that result.

Change:
In parse_self():
accept 7F 45 4C 46 as well as the SELF magic;
for a raw ELF, read the ELF header and program headers from offset 0 with the
same validation as before (x86-64, 56-byte program headers, size limit);
check every segment lies inside the file, then return the data as the ELF with
no missing metadata headers.
The SELF path is unchanged.

Test:
Manual: a clean 1.09 Itemzflow dump (raw ELF, 89.3 MiB) failed at prepare.py before and launches now. 
Player is able to make a character, start a new save, and play normally with a clean 1.09 Itemzflow dump.
2026-11-02 01:54:21 -03:00
Mrsuss60 85d9bc98da Merge branch 'master' of https://github.com/Mrsuss60/bloodborne_pc_windows_port 2026-11-01 21:42:11 +00:00
Mrsuss60 7889ca068a launcher: update launcher version to v0.8.0 2026-11-01 21:41:59 +00:00
MrSuS60 0176a88b3f Update README.md 2026-11-01 19:06:27 +01:00
55 changed files with 5503 additions and 2221 deletions

No files matched your search

+1
View File
@@ -7,6 +7,7 @@
[![Last commit](https://flat.badgen.net/github/last-commit/Mrsuss60/bloodborne_pc_windows_port?label=LAST%20COMMIT&labelColor=1a0f0f&color=6b6b6b&icon=git)](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
View File
@@ -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> &nbsp;<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
+776 -12
View File
@@ -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
View File
@@ -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']} &middot; {p_clock}" + (f" &middot; {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())
+7
View File
@@ -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
View File
@@ -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
+3 -1
View File
@@ -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
+203 -44
View File
@@ -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;
+31 -4
View File
@@ -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) {
{
+4
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+14
View File
@@ -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
+11
View File
@@ -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");
+15
View File
@@ -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;
};
+4
View File
@@ -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};
+202 -199
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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");
}