mirror of
https://github.com/Mrsuss60/bloodborne_pc_windows_port.git
synced 2026-11-07 05:56:20 -10:00
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.
211 lines
8.8 KiB
Python
211 lines
8.8 KiB
Python
#!/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)
|