mirror of
https://github.com/ScreamAngel/gow3_launcher.git
synced 2026-11-07 08:15:57 -10:00
354 lines
13 KiB
Python
354 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 sys
|
|
import tempfile
|
|
|
|
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
|
|
|
|
|
|
KNOWN_GOW3_DIRS = {
|
|
'exec', 'dutch', 'english', 'french', 'german',
|
|
'italian', 'polish', 'portuguese', 'russian', 'spanish'
|
|
}
|
|
|
|
|
|
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>/EXEC,
|
|
<mod>/Game/EXEC, XML patches, and wrapper folders around them."""
|
|
folder = Path(folder)
|
|
if not folder.is_dir():
|
|
return None
|
|
if folder.name.casefold() in ('gow3-windows', 'gow3-linux') or (folder / 'run.py').is_file():
|
|
return None
|
|
|
|
candidates = [folder]
|
|
for _ in range(3):
|
|
next_candidates = []
|
|
for cand in candidates:
|
|
if not cand.is_dir():
|
|
continue
|
|
if child(cand, 'dvdroot_ps4').is_dir():
|
|
return cand, ''
|
|
for d in KNOWN_GOW3_DIRS:
|
|
if child(cand, d).is_dir():
|
|
return cand, ''
|
|
if list(cand.glob('*.xml')) or child(cand, 'patches').is_dir():
|
|
return cand, ''
|
|
for sub in cand.iterdir():
|
|
if sub.is_dir() and not sub.name.startswith('.'):
|
|
next_candidates.append(sub)
|
|
candidates = next_candidates
|
|
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 the game root."""
|
|
layout = content_root(folder)
|
|
if not layout:
|
|
if list(Path(folder).rglob('*.xml')):
|
|
return
|
|
raise ValueError(f'{folder}: expected EXEC/ or dvdroot_ps4/ 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)
|
|
# Skip documentation / metadata at the mod root
|
|
if len(relative.parts) == 1:
|
|
if source.suffix.casefold() in ('.txt', '.md', '.json', '.jpg', '.png', '.ini'):
|
|
continue
|
|
# Strip packaging wrapper prefixes (app0, dvdroot_ps4, game) so files align with the game root
|
|
while len(relative.parts) > 1 and relative.parts[0].casefold() in ('app0', 'dvdroot_ps4', 'game'):
|
|
relative = Path(*relative.parts[1:])
|
|
|
|
# Skip patch XML files in mod folder (handled by patches.py)
|
|
if source.suffix.casefold() == '.xml':
|
|
continue
|
|
|
|
if relative.parts[0].casefold() in ('eboot.bin', 'sce_module', 'sce_sys'):
|
|
if relative.parts[0].casefold() in ('eboot.bin', 'sce_module'):
|
|
raise ValueError(f'Executable/system replacement is unsupported: {source}')
|
|
continue
|
|
if not source.is_file():
|
|
continue
|
|
yield relative, source
|
|
|
|
|
|
def conflicts(root, names):
|
|
"""Files more than one of `names` (in load order) replaces: [(game path, [mods])], the last
|
|
mod of each list wins. Paths match case-insensitively, as in build_overlay; unreadable mods
|
|
are skipped."""
|
|
owners = {}
|
|
for name in names:
|
|
try:
|
|
for relative, _source in mod_files(Path(root) / name):
|
|
key = relative.as_posix().casefold()
|
|
owners.setdefault(key, (relative.as_posix(), []))[1].append(name)
|
|
except (OSError, ValueError):
|
|
continue
|
|
return sorted((path, mods) for path, mods in owners.values() if len(mods) > 1)
|
|
|
|
|
|
def apply_preset(preset, available):
|
|
"""(order, disabled) of a saved preset for the mods present now: missing ones are dropped,
|
|
new ones go last and enabled."""
|
|
order = preset.get('order') if isinstance(preset.get('order'), list) else []
|
|
disabled = preset.get('disabled') if isinstance(preset.get('disabled'), list) else []
|
|
order = list(dict.fromkeys(n for n in [*order, *available] if n in available))
|
|
return order, [n for n in disabled if n in available]
|
|
|
|
|
|
# Windows without the symlink privilege (Developer Mode off): directories become junctions,
|
|
# files hard links (copies across volumes). Those are not symlinks, so the overlay remembers
|
|
# what it linked and where to.
|
|
LINKED = {}
|
|
|
|
|
|
def make_link(link, target):
|
|
if link.is_symlink() or link.is_file():
|
|
try:
|
|
link.unlink()
|
|
except OSError:
|
|
pass
|
|
elif link.is_dir():
|
|
try:
|
|
remove_link(link)
|
|
except OSError:
|
|
pass
|
|
try:
|
|
link.symlink_to(target, target_is_directory=target.is_dir())
|
|
return
|
|
except OSError:
|
|
if os.name != 'nt':
|
|
raise
|
|
if target.is_dir():
|
|
import _winapi
|
|
try:
|
|
_winapi.CreateJunction(str(target), str(link))
|
|
except OSError:
|
|
# Fallback if junction creation fails across volumes or permissions
|
|
shutil.copytree(target, link, dirs_exist_ok=True)
|
|
else:
|
|
try:
|
|
os.link(target, link)
|
|
except OSError:
|
|
shutil.copy2(target, link)
|
|
p_str = str(link)
|
|
LINKED[p_str] = target
|
|
LINKED[p_str.casefold()] = target
|
|
|
|
|
|
def symlinks_work(directory):
|
|
probe = Path(tempfile.mkdtemp(prefix='link-test-', dir=directory))
|
|
try:
|
|
(probe / 'link').symlink_to(probe, target_is_directory=True)
|
|
return True
|
|
except OSError:
|
|
return False
|
|
finally:
|
|
shutil.rmtree(probe, ignore_errors=True)
|
|
|
|
|
|
def is_link(path):
|
|
p_str = str(path)
|
|
if path.is_symlink():
|
|
return True
|
|
if p_str in LINKED or p_str.casefold() in LINKED:
|
|
return True
|
|
if hasattr(path, 'is_junction') and path.is_junction():
|
|
return True
|
|
if os.name == 'nt':
|
|
try:
|
|
import stat
|
|
mode = os.lstat(path).st_mode
|
|
if stat.S_ISLNK(mode):
|
|
return True
|
|
except OSError:
|
|
pass
|
|
return False
|
|
|
|
|
|
def link_target(path):
|
|
p_str = str(path)
|
|
return LINKED.get(p_str) or LINKED.get(p_str.casefold()) or path.resolve(strict=True)
|
|
|
|
|
|
def remove_link(path):
|
|
p_str = str(path)
|
|
try:
|
|
if path.is_symlink():
|
|
path.unlink()
|
|
elif hasattr(path, 'is_junction') and path.is_junction():
|
|
os.rmdir(path)
|
|
elif path.is_dir():
|
|
os.rmdir(path)
|
|
else:
|
|
path.unlink()
|
|
except OSError:
|
|
try:
|
|
if path.is_symlink():
|
|
path.unlink()
|
|
elif os.path.isdir(path):
|
|
os.rmdir(path)
|
|
else:
|
|
path.unlink()
|
|
except OSError:
|
|
pass
|
|
LINKED.pop(p_str, None)
|
|
LINKED.pop(p_str.casefold(), None)
|
|
|
|
|
|
def expand(directory):
|
|
"""Materialize one directory level; never write through a directory link."""
|
|
if is_link(directory):
|
|
target = link_target(directory)
|
|
if not target.is_dir():
|
|
raise ValueError(f'File/directory conflict at {directory.name}')
|
|
remove_link(directory)
|
|
directory.mkdir(exist_ok=True)
|
|
for entry in target.iterdir():
|
|
make_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 on_rm_error(func, path, exc_info):
|
|
try:
|
|
os.chmod(path, 0o777)
|
|
if os.path.isdir(path):
|
|
os.rmdir(path)
|
|
else:
|
|
os.unlink(path)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
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)
|
|
for old_dir in out.glob('mod-game-*'):
|
|
if old_dir.is_dir():
|
|
shutil.rmtree(old_dir, onerror=on_rm_error)
|
|
if os.name == 'nt' and not symlinks_work(out) and out.drive.casefold() != game.drive.casefold():
|
|
# Hard links only reach files on the same volume: the overlay goes beside the game.
|
|
out = game.parent
|
|
overlay = Path(tempfile.mkdtemp(prefix='mod-game-', dir=out))
|
|
try:
|
|
for entry in game.iterdir():
|
|
make_link(overlay / entry.name, entry)
|
|
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 is_link(destination):
|
|
replaced += link_target(destination).is_relative_to(game)
|
|
remove_link(destination)
|
|
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, onerror=on_rm_error)
|
|
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)
|