mirror of
https://github.com/Awakenchan/LanternEngine.git
synced 2026-11-10 09:21:42 -10:00
327 lines
13 KiB
Python
327 lines
13 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 stat
|
|
import sys
|
|
import tempfile
|
|
|
|
WINDOWS = os.name == 'nt'
|
|
|
|
GAME_FOLDERS = {'action', 'chr', 'event', 'facegen', 'font', 'map', 'menu', 'movie', 'msg', 'mtd',
|
|
'obj', 'other', 'param', 'paramdef', 'parts', 'remo', 'script', 'sfx', 'shader',
|
|
'sound'}
|
|
|
|
SERIALS = ('CUSA00207', 'CUSA00208', 'CUSA00299', 'CUSA00900', 'CUSA01363', 'CUSA03014',
|
|
'CUSA03023', 'CUSA03173', 'CUSA03179')
|
|
|
|
|
|
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>/<serial>/dvdroot_ps4
|
|
(CUSA03173 or another retail serial), 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') + SERIALS:
|
|
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')
|
|
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 [n for n in folders if (directory / n).is_symlink()]:
|
|
print(f'Mods: skipped the link {directory / name} (links are not followed)', file=sys.stderr)
|
|
folders.remove(name)
|
|
for name in sorted(files):
|
|
source = directory / name
|
|
if source.is_symlink():
|
|
print(f'Mods: skipped the link {source} (links are not followed)', file=sys.stderr)
|
|
continue
|
|
relative = Path(prefix) / source.relative_to(root) if prefix else source.relative_to(root)
|
|
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 app_version(folder):
|
|
"""APP_VER of a game folder's sce_sys/param.sfo, or None."""
|
|
try:
|
|
from prepare import sfo
|
|
return sfo((Path(folder) / 'sce_sys' / 'param.sfo').read_bytes()).get('APP_VER')
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def update_folder(game):
|
|
"""The dumped game update beside the game, as shadPS4 keeps it (<game>-UPDATE or <game>-patch,
|
|
any case), when it holds an eboot.bin and the game is not already at its version: it is layered
|
|
over the game, which stays unchanged."""
|
|
game = Path(game)
|
|
for entry in (game.parent.iterdir() if game.parent.is_dir() else ()):
|
|
if entry.is_dir() and entry.name.casefold() in (f'{game.name}-update'.casefold(),
|
|
f'{game.name}-patch'.casefold()) and child(entry, 'eboot.bin').is_file():
|
|
version = app_version(entry)
|
|
if version and app_version(game) == version:
|
|
print(f'Game update: {entry.name} is already in the game ({version})', file=sys.stderr)
|
|
return None
|
|
return entry
|
|
return None
|
|
|
|
|
|
def overlay_base(game, out):
|
|
"""Where the overlay is built: `out`, or beside the game when `out` is on another drive (hard
|
|
links work within one volume only; across volumes every linked file was copied)."""
|
|
if WINDOWS and os.path.splitdrive(str(Path(out).resolve()))[0].casefold() != os.path.splitdrive(str(game))[0].casefold():
|
|
return game.parent / '.bbport-overlays'
|
|
return Path(out)
|
|
|
|
|
|
def update_files(folder):
|
|
"""(game path, source) of every file of a game update: executable and system files too."""
|
|
root = Path(folder).resolve()
|
|
for directory, folders, files in os.walk(root, followlinks=False):
|
|
directory = Path(directory)
|
|
for name in sorted(files):
|
|
source = directory / name
|
|
if source.is_file() and not source.is_symlink():
|
|
yield source.relative_to(root), source
|
|
|
|
|
|
def is_link(path):
|
|
return path.is_symlink() or (WINDOWS and path.is_junction())
|
|
|
|
|
|
def link(path, target):
|
|
"""`path` refers to `target` (a directory or a file) without copying the game."""
|
|
if not WINDOWS:
|
|
path.symlink_to(target, target_is_directory=target.is_dir())
|
|
elif target.is_dir():
|
|
import _winapi
|
|
_winapi.CreateJunction(str(target), str(path))
|
|
else:
|
|
try:
|
|
os.link(target, path)
|
|
except OSError:
|
|
shutil.copy2(target, path)
|
|
|
|
|
|
def unlink(path):
|
|
"""Removes a link made by `link` (never what it refers to)."""
|
|
if WINDOWS and path.is_junction():
|
|
os.rmdir(path)
|
|
return
|
|
try:
|
|
path.unlink()
|
|
except PermissionError:
|
|
if not WINDOWS or is_link(path):
|
|
raise
|
|
os.chmod(path, stat.S_IWRITE)
|
|
path.unlink()
|
|
|
|
|
|
def remove_overlay(path):
|
|
"""Deletes an overlay from build_overlay without following its links."""
|
|
path = Path(path)
|
|
if is_link(path) or not path.is_dir():
|
|
if is_link(path) or path.exists():
|
|
unlink(path)
|
|
return
|
|
for entry in path.iterdir():
|
|
remove_overlay(entry)
|
|
os.rmdir(path)
|
|
|
|
|
|
def expand(directory):
|
|
"""Materialize one directory level; never write through a directory link."""
|
|
if is_link(directory):
|
|
target = directory.resolve(strict=True)
|
|
if not target.is_dir():
|
|
raise ValueError(f'File/directory conflict at {directory.name}')
|
|
unlink(directory)
|
|
directory.mkdir()
|
|
for entry in target.iterdir():
|
|
link(directory / entry.name, entry)
|
|
elif directory.exists() and not directory.is_dir():
|
|
raise ValueError(f'File/directory conflict at {directory.name}')
|
|
else:
|
|
directory.mkdir(exist_ok=True)
|
|
|
|
|
|
def remove_stale_overlays(out):
|
|
"""Deletes the overlays earlier starts left in `out`: run_windows.py removes its own when the
|
|
game ends, but not when the launcher's Stop (or its "still running" prompt) kills it with the
|
|
game. One still in use (an in-game restart's previous run, stopping) is left for the next start."""
|
|
for overlay in Path(out).glob('mod-game-*'):
|
|
if overlay.is_dir() and not is_link(overlay):
|
|
try:
|
|
remove_overlay(overlay)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
GAMEPLAY_FOLDERS = ('dvdroot_ps4/param/', 'dvdroot_ps4/event/', 'dvdroot_ps4/script/',
|
|
'dvdroot_ps4/map/mapstudio/', 'dvdroot_ps4/action/')
|
|
|
|
|
|
def changes_gameplay(key):
|
|
"""A mod file the other players of a co-op session read too (parameters, map layouts, events,
|
|
AI and talk scripts, actions, character animations): different ones on each side desync enemies."""
|
|
return key.startswith(GAMEPLAY_FOLDERS) or (
|
|
key.startswith('dvdroot_ps4/chr/') and ('.anibnd' in key or '.behbnd' in key))
|
|
|
|
|
|
def build_overlay(game, out, mods, update=None):
|
|
game = Path(game).resolve(strict=True)
|
|
out = overlay_base(game, out)
|
|
if Path(out).is_dir():
|
|
remove_stale_overlays(out)
|
|
replacements = []
|
|
owners = {}
|
|
if update:
|
|
files = list(update_files(update))
|
|
for relative, source in files:
|
|
owners[relative.as_posix().casefold()] = Path(update).name
|
|
replacements.append((relative, source))
|
|
print(f'Game update: {Path(update).name}: {len(files)} files over the game', file=sys.stderr)
|
|
gameplay = 0
|
|
for name, root in mods:
|
|
count = 0
|
|
for relative, source in mod_files(root):
|
|
key = relative.as_posix().casefold()
|
|
gameplay += changes_gameplay(key)
|
|
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 gameplay:
|
|
print(f'Mods: {gameplay} of these files change gameplay (parameters, map layouts, events, AI, '
|
|
'animations): co-op partners need the same files, or enemies and items desync between you',
|
|
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():
|
|
link(overlay / entry.name, entry)
|
|
replaced = added = 0
|
|
for relative, source in replacements:
|
|
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 WINDOWS and destination.is_file():
|
|
replaced += 1
|
|
unlink(destination)
|
|
elif destination.exists():
|
|
raise ValueError(f'File/directory conflict: {relative}')
|
|
else:
|
|
added += 1
|
|
link(destination, source)
|
|
print(f'Mods: {replaced} game files replaced, {added} added', file=sys.stderr)
|
|
return overlay
|
|
except BaseException:
|
|
remove_overlay(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')
|
|
parser.add_argument('--first-layer', type=Path, action='append', default=[])
|
|
args = parser.parse_args()
|
|
layers = [(layer.name, layer) for layer in args.first_layer]
|
|
if args.enabled == '1':
|
|
legacy = Path(str(args.game.resolve()) + '-mods')
|
|
if legacy.is_dir():
|
|
layers.append((legacy.name, legacy))
|
|
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, update_folder(args.game.resolve())))
|
|
|
|
|
|
if __name__ == '__main__':
|
|
try:
|
|
main()
|
|
except (OSError, ValueError, TypeError) as error:
|
|
print(f'Mods: {error}', file=sys.stderr)
|
|
sys.exit(1)
|