Files
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

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)