mirror of
https://github.com/Liondarkz/issn_pc.git
synced 2026-11-07 04:36:01 -10:00
Right after art/cache/all_shaders.xpps is read, the game looks its shaders up by name and finds the table empty (rcx = 0): the package holds none of the shaders the 01.07 eboot.bin asks for. The fault report now ends with a STOP line in plain words, and run.py logs the package's size and the folder it really comes from (update or base game) so the next log shows a mismatch. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_014CsPEH9HsibG9QrEt8o9SE
574 lines
30 KiB
Python
574 lines
30 KiB
Python
#!/usr/bin/env python3
|
|
# SPDX-License-Identifier: GPL-2.0-or-later
|
|
"""Prepares the game image and starts issn-probe (Windows and Linux; run.sh wraps it on Linux).
|
|
|
|
Environment variables (ISSN_GAME_DIR, ISSN_DATA_DIR, ISSN_PATCHES, ISSN_MODS_*,
|
|
ISSN_USER_DIR, ...). Extra arguments go to issn-probe.
|
|
The in-game "Apply and restart" runs this script again through ISSN_RESTART_COMMAND;
|
|
`--after PID` waits for the previous game process to end first (its GPU device and memory).
|
|
"""
|
|
import os
|
|
import re
|
|
from pathlib import Path
|
|
import shlex
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
|
|
WINDOWS = sys.platform == 'win32'
|
|
EXE_SUFFIX = '.exe' if WINDOWS else ''
|
|
if WINDOWS:
|
|
import ctypes
|
|
|
|
PORT = Path(__file__).resolve().parent
|
|
|
|
|
|
|
|
def shader_seed_name(port, user_dir):
|
|
"""The folder name in shader-seed.txt when that folder is in user_dir/cache, else None."""
|
|
try:
|
|
name = (Path(port) / 'shader-seed.txt').read_text().strip()
|
|
except OSError:
|
|
return None
|
|
if not name or not all(c.isalnum() or c in '-_.' for c in name) or name.startswith('.'):
|
|
return None
|
|
target = Path(user_dir) / 'cache' / name
|
|
shipped = Path(port) / 'user' / 'cache' / name
|
|
# A user folder elsewhere (Documents\inFAMOUS-PC, the launcher's choice) gets a copy of the
|
|
# seed the package ships: it is a fresh folder named after the release, never merged into a
|
|
# cache that was played with (package.sh). Without it those players compiled every shader
|
|
# while playing (tester logs, 10-29: "no valid seed-stamp.bin in the cache folder").
|
|
if not target.is_dir() and (shipped / 'seed-stamp.bin').is_file():
|
|
part = target.with_name(name + '.part')
|
|
try:
|
|
shutil.rmtree(part, ignore_errors=True)
|
|
shutil.copytree(shipped, part, dirs_exist_ok=True)
|
|
# 10-30: the rename can fail right after the copy (an antivirus holds the new files):
|
|
# tried again for a while, then the seed is copied under its own name.
|
|
for attempt in range(20):
|
|
try:
|
|
part.rename(target)
|
|
break
|
|
except OSError:
|
|
if attempt == 19:
|
|
shutil.copytree(shipped, target, dirs_exist_ok=True)
|
|
shutil.rmtree(part, ignore_errors=True)
|
|
else:
|
|
time.sleep(0.5)
|
|
print(f'Shader seed {name} copied to {target.parent}', flush=True)
|
|
except OSError as error:
|
|
print(f'Shader seed: could not copy it to {target.parent} ({error})', flush=True)
|
|
return name if target.is_dir() else None
|
|
|
|
def fail(message):
|
|
print(message, file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
def wait_for(pid):
|
|
if not WINDOWS:
|
|
deadline = time.monotonic() + 30
|
|
while time.monotonic() < deadline:
|
|
try:
|
|
os.kill(pid, 0)
|
|
except ProcessLookupError:
|
|
return
|
|
except PermissionError:
|
|
pass
|
|
time.sleep(0.1)
|
|
return
|
|
kernel32 = ctypes.windll.kernel32
|
|
SYNCHRONIZE = 0x00100000
|
|
handle = kernel32.OpenProcess(SYNCHRONIZE, False, pid)
|
|
if handle:
|
|
kernel32.WaitForSingleObject(handle, 30000)
|
|
kernel32.CloseHandle(handle)
|
|
|
|
|
|
def no_console():
|
|
"""Started without a console (the launcher): console programs must not open their own."""
|
|
if not WINDOWS:
|
|
return 0
|
|
return 0 if ctypes.windll.kernel32.GetConsoleWindow() else subprocess.CREATE_NO_WINDOW
|
|
|
|
|
|
def parse_cpu_features(output):
|
|
"""Instruction sets `issn-probe --cpu-features` reports missing ([] when complete)."""
|
|
for line in output.splitlines():
|
|
if line.startswith('CPU: missing '):
|
|
return [name.strip() for name in line[len('CPU: missing '):].split(',') if name.strip()]
|
|
return []
|
|
|
|
|
|
def isa_rewrite_wanted(missing, env):
|
|
"""ISSN_ISA_REWRITE=1 (or sse4a: the EXTRQ sites only) forces the ISA rewrite table, 0 disables
|
|
it; otherwise the CPU decides."""
|
|
policy = env.get('ISSN_ISA_REWRITE')
|
|
if policy in ('0', '1', 'sse4a'):
|
|
return policy != '0'
|
|
return bool(missing)
|
|
|
|
|
|
def run_script(name, *args, capture=False, check=True):
|
|
# Inside the packaged launcher executable (PyInstaller) the executable runs scripts itself.
|
|
script = ['--script'] if getattr(sys, 'frozen', False) else []
|
|
command = [sys.executable, *script, str(PORT / 'scripts' / name), *map(str, args)]
|
|
env = {**os.environ, 'PYTHONIOENCODING': 'utf-8'}
|
|
if capture:
|
|
result = subprocess.run(command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, text=True,
|
|
encoding='utf-8', env=env, creationflags=no_console())
|
|
if not check:
|
|
return result.returncode, result.stdout
|
|
if result.returncode:
|
|
sys.exit(result.returncode)
|
|
return result.stdout
|
|
# stdin given: the output handles are passed explicitly (a windowed launcher child would
|
|
# get none otherwise).
|
|
result = subprocess.run(command, stdin=subprocess.DEVNULL, env=env, creationflags=no_console())
|
|
if result.returncode:
|
|
sys.exit(result.returncode)
|
|
return None
|
|
|
|
|
|
def launch_prefix(env):
|
|
"""Linux integrations chosen in the launcher: MangoHud is its Vulkan layer (an environment
|
|
variable), GameMode a command that starts the game."""
|
|
if WINDOWS:
|
|
return []
|
|
if env.get('ISSN_MANGOHUD') == '1':
|
|
env.setdefault('MANGOHUD', '1')
|
|
if env.get('ISSN_GAMEMODE') == '1':
|
|
if gamemode := shutil.which('gamemoderun'):
|
|
return [gamemode]
|
|
print('GameMode: gamemoderun not found, starting without it', flush=True)
|
|
return []
|
|
|
|
|
|
def find_executable(name):
|
|
"""bin/ (packaged), else out/ (built with build.sh)."""
|
|
for candidate in (PORT / 'bin' / name, PORT / 'out' / name):
|
|
if candidate.is_file():
|
|
return candidate
|
|
return None
|
|
|
|
|
|
def glyph_mounts(env, probe, game, out):
|
|
"""(probe arguments, style) of the controller glyphs: patched copies of the WADs that hold the
|
|
game's button icons, mounted over them (scripts/glyphs.py). Opt-in: unless ISSN_GLYPH_STYLE is
|
|
auto, xbox or nintendo nothing is asked, drawn or mounted, and old copies are removed. The
|
|
game folder is never written."""
|
|
choice = env.get('ISSN_GLYPH_STYLE', 'off')
|
|
import game_profiles
|
|
import patches as game_patches
|
|
if game_patches.game_title_id(game) in game_profiles.INFAMOUS_SECOND_SON:
|
|
return infamous_glyph_mounts(env, probe, game, out, choice)
|
|
if choice not in ('auto', 'xbox', 'nintendo'):
|
|
shutil.rmtree(Path(out) / 'glyphs', ignore_errors=True)
|
|
return [], None
|
|
try:
|
|
import glyphs
|
|
pad = ''
|
|
if choice == 'auto':
|
|
pad = subprocess.run([str(probe), '--gamepad'], stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
|
|
text=True, timeout=15, creationflags=no_console()).stdout
|
|
style, faces, name = glyphs.resolve(choice, pad)
|
|
mounts = glyphs.build(game, out, style, faces, env.get('ISSN_PAD_MAP', ''))
|
|
except Exception as error: # noqa: BLE001 (icons only: whatever fails, the game still starts)
|
|
print(f'Glyphs: {error}; the game keeps its own button icons', flush=True)
|
|
return [], None
|
|
if not mounts:
|
|
print('Glyphs: no Xbox or Nintendo controller, the game keeps its own button icons' if style == 'playstation'
|
|
else 'Glyphs: no button icons found in this game folder', flush=True)
|
|
return [], None
|
|
print(f'Glyphs: {style}{f" ({name})" if name else ""}, on copies in {Path(out) / "glyphs"}; game files untouched',
|
|
flush=True)
|
|
return [part for guest, path in mounts.items() for part in ('--mount', f'{guest}={path}')], style
|
|
|
|
|
|
def infamous_glyph_mounts(env, probe, game, out, choice):
|
|
"""inFAMOUS Second Son's button icons (scripts/infamous_glyphs.py): keyboard and mouse keys or
|
|
Xbox letters drawn into copies of the language packages' icon atlas. auto: the connected pad's
|
|
style (a PlayStation pad keeps the game's own), the keyboard without one. The tester idea of
|
|
u/humonculus87 (Reddit)."""
|
|
import infamous_glyphs
|
|
style = choice if choice in ('keyboard', 'xbox') else None
|
|
name = ''
|
|
if choice == 'auto':
|
|
try:
|
|
import glyphs
|
|
pad = subprocess.run([str(probe), '--gamepad'], stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
|
|
text=True, timeout=15, creationflags=no_console()).stdout
|
|
found = glyphs.parse_gamepad(pad)
|
|
except Exception: # noqa: BLE001 (icons only)
|
|
found = None
|
|
style = 'keyboard' if not found else None if found[0] == 'playstation' else 'xbox'
|
|
name = found[2] if found else ''
|
|
if not style:
|
|
infamous_glyphs.build(game, out, 'off')
|
|
if choice == 'auto':
|
|
print(f'Button icons: the game\'s own (PlayStation controller{f" {name}" if name else ""})', flush=True)
|
|
return [], None
|
|
try:
|
|
mounts = infamous_glyphs.build(game, out, style, env.get('ISSN_KEY_MAP', ''))
|
|
except Exception as error: # noqa: BLE001 (icons only: whatever fails, the game still starts)
|
|
print(f'Button icons: {error}; the game keeps its own', flush=True)
|
|
return [], None
|
|
if not mounts:
|
|
print('Button icons: no language package with the icon font in this game folder', flush=True)
|
|
return [], None
|
|
label = {'keyboard': 'keyboard and mouse', 'xbox': 'Xbox'}[style]
|
|
languages = sum(1 for guest in mounts if '/lang_' in guest)
|
|
prompts = ', in-world prompts' if len(mounts) > languages else ''
|
|
print(f'Button icons: {label}{f" ({name})" if name else ""}, {languages} language packages{prompts} redrawn '
|
|
f'in {Path(out) / "glyphs"}; game files untouched', flush=True)
|
|
return [part for guest, path in mounts.items() for part in ('--mount', f'{guest}={path}')], style
|
|
|
|
|
|
def main():
|
|
args = sys.argv[1:]
|
|
if len(args) >= 2 and args[0] == '--after':
|
|
wait_for(int(args[1]))
|
|
args = args[2:]
|
|
# As run.sh: relative paths (ISSN_GAME_DIR, ISSN_DATA_DIR, fsr4_shaders/) are from the port.
|
|
os.chdir(PORT)
|
|
env = os.environ
|
|
# The port's settings were called GOW3_* before the rename (it began as the God of War III
|
|
# port): old commands and scripts keep working, an ISSN_* name wins when both are set.
|
|
for key in [k for k in env if k.startswith('GOW3_')]:
|
|
env.setdefault('ISSN_' + key[5:], env[key])
|
|
data = Path(env.get('ISSN_DATA_DIR', PORT))
|
|
out = data / 'out'
|
|
out.mkdir(parents=True, exist_ok=True)
|
|
env.setdefault('ISSN_CONFIG', str(data / 'issn.ini'))
|
|
# Values this script derived on the previous start ("Apply and restart" inherits them) are
|
|
# derived again from the new options.
|
|
for key in env.pop('ISSN_DERIVED', '').split(','):
|
|
env.pop(key, None)
|
|
derived = []
|
|
sys.path.insert(0, str(PORT / 'scripts'))
|
|
import graphics_options
|
|
graphics, reverted = graphics_options.prepare(env['ISSN_CONFIG'], out / 'graphics_state.json')
|
|
if reverted:
|
|
print('Graphics: the last restart never reached the game; previous options restored', flush=True)
|
|
# issn: a variable set by hand (a test run) wins over issn.ini; the ones taken from the ini
|
|
# are derived, so a restart from the in-game menu takes the ini's new values.
|
|
for key, value in graphics_options.environment(graphics).items():
|
|
if key not in env:
|
|
env[key] = value
|
|
derived.append(key)
|
|
# issn-axy: a restart from the in-game menu starts its anti-aliasing / quality / frame gen.
|
|
ini = graphics_options.read_ini(env['ISSN_CONFIG'])
|
|
if ini.get('restart_unconfirmed') == '1' and not reverted:
|
|
menu_env, menu_off = graphics_options.menu_environment(ini, WINDOWS)
|
|
for key in menu_off:
|
|
env.pop(key, None)
|
|
env.update(menu_env)
|
|
if menu_env or menu_off:
|
|
print('Graphics: the in-game menu\'s anti-aliasing for this restart: '
|
|
+ (', '.join(f'{k}={v}' for k, v in menu_env.items()) or "the game's own"), flush=True)
|
|
if not env.get('ISSN_FSR411_DIR') and not (PORT / 'fsr4_411').is_dir() and (data / 'fsr4_411').is_dir():
|
|
env['ISSN_FSR411_DIR'] = str(data / 'fsr4_411')
|
|
|
|
probe = Path(env['ISSN_PROBE']) if env.get('ISSN_PROBE') else find_executable('issn-probe' + EXE_SUFFIX)
|
|
if not probe or not probe.is_file():
|
|
fail(f'issn-probe{EXE_SUFFIX} not found: build it with build.sh or use a packaged build.')
|
|
# Development builds run from the MSYS2 tree: its CLANG64 DLLs (SDL3, FFmpeg, ...).
|
|
if WINDOWS and not (probe.parent / 'SDL3.dll').is_file():
|
|
clang64 = Path(env.get('MSYS2_ROOT', r'C:\msys64')) / 'clang64' / 'bin'
|
|
if clang64.is_dir():
|
|
env['PATH'] = f'{clang64}{os.pathsep}{env.get("PATH", "")}'
|
|
# Instruction sets this CPU lacks (MOVBE, BMI1, LZCNT): the ISA rewrite covers them; exit 20
|
|
# is a set nothing covers.
|
|
features = subprocess.run([str(probe), '--cpu-features'], stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
|
|
text=True, creationflags=no_console())
|
|
if features.returncode == 20:
|
|
print(features.stdout, end='', flush=True)
|
|
sys.exit(20)
|
|
missing = parse_cpu_features(features.stdout)
|
|
|
|
game = Path(env.get('ISSN_GAME_DIR', PORT.parent / 'CUSA00004'))
|
|
if not (game / 'eboot.bin').is_file():
|
|
fail(f'No eboot.bin in {game} (set ISSN_GAME_DIR).')
|
|
update_view = None
|
|
if env.get('ISSN_PATCH_DIR'):
|
|
import game_update
|
|
try:
|
|
update_view = game_update.create_view(game, env['ISSN_PATCH_DIR'], out)
|
|
except (ValueError, OSError) as error:
|
|
fail(str(error))
|
|
game = update_view
|
|
print(f'Game and update combined from {env["ISSN_PATCH_DIR"]}', flush=True)
|
|
original_game = game.resolve()
|
|
# issn 11-05 (#33): the shader package the game reads first; its size and the folder it really
|
|
# comes from (the update or the base game) tell a mismatched dump at a glance.
|
|
shaders = game / 'art' / 'cache' / 'all_shaders.xpps'
|
|
try:
|
|
print(f'Game data: all_shaders.xpps {shaders.stat().st_size:,} bytes from {shaders.resolve().parent.parent.parent}',
|
|
flush=True)
|
|
except OSError as error:
|
|
print(f'Game data: art/cache/all_shaders.xpps missing or unreadable ({error})', flush=True)
|
|
# issn_pc: the port is made for the 01.07 update (code patches, mouse hook, seed). A base 01.00
|
|
# dump ends in an unsupported system call after the intro (sceKernelLoadStartModule, tester log
|
|
# 10-24) - say so here instead. ISSN_ALLOW_OLD_VERSION=1 tries anyway.
|
|
import game_profiles
|
|
import patches as game_patches
|
|
# Decided by the eboot.bin: some dumps carry the 01.07 eboot.bin under the base game's
|
|
# param.sfo (APP_VER 01.00; tester log 10-25 played that way).
|
|
title, version = game_patches.game_title_id(game), game_patches.game_app_version(game)
|
|
if (title in game_profiles.INFAMOUS_SECOND_SON and version and version != '01.07'
|
|
and env.get('ISSN_ALLOW_OLD_VERSION') != '1'
|
|
and game_patches.eboot_sha256(game) not in game_profiles.INFAMOUS_107_EBOOTS):
|
|
fail(f'{title} version {version}: this port needs the game\'s 01.07 update. In the launcher, '
|
|
'set "Update folder" to the 01.07 update (the folder with its eboot.bin and sce_sys), or '
|
|
'lay the update over the game folder, then press PLAY again.')
|
|
game = Path(run_script('mods.py', game, '--out', out,
|
|
'--mods-dir', env.get('ISSN_MODS_DIR', data / 'mods'),
|
|
'--config', env.get('ISSN_MODS_CONFIG', data / 'mods.json'),
|
|
'--enabled', env.get('ISSN_MODS_ENABLED', '1'), capture=True).strip())
|
|
mod_view = game if game.resolve() != original_game else None
|
|
try:
|
|
run_script('prepare.py', game, '--out', out)
|
|
# issn: link_libc.py (boot-libc.bin, superseded by link_modules.py) is not run: nothing
|
|
# loads its output, and it rejects older SDKs' libc.prx (inFAMOUS Second Son).
|
|
run_script('link_modules.py', game, '--out', out)
|
|
# A failed rewrite prints its reason and the game starts without the table.
|
|
isa_args = []
|
|
if isa_rewrite_wanted(missing, env):
|
|
code, output = run_script('isa_rewrite.py', out / 'boot-linked.bin', '--out', out,
|
|
capture=True, check=False)
|
|
print(output, end='', flush=True)
|
|
if code == 0:
|
|
isa_args = ['--isa-rewrite', str(out / 'isa-rewrite.bin')]
|
|
run_script('content_profile.py', game, '--out', out, '--sku', env.get('ISSN_CONTENT_SKU', 'full'))
|
|
|
|
# issn: settings a game needs (scripts/game_profiles.py); derived again on each start.
|
|
import game_profiles
|
|
title, game_settings = game_profiles.environment(game, env)
|
|
if game_settings:
|
|
env.update(game_settings)
|
|
derived.extend(game_settings)
|
|
print(f'Game profile {title}: ' + ', '.join(f'{k}={v}' for k, v in game_settings.items()), flush=True)
|
|
|
|
import patches
|
|
from patches import game_profile_for, patch_requirements, patch_status, selected_patches
|
|
profile = game_profile_for(game)
|
|
engine_fps = 60
|
|
# The window shows the game's icon (BMP: the format SDL loads without extra libraries); on
|
|
# Linux the launcher's own icon (the AppImage ships the one chosen when it was built) and
|
|
# an application id the desktop entry matches, so the shell shows the same icon.
|
|
icon_file = game / 'sce_sys' / 'icon0.png'
|
|
if not WINDOWS:
|
|
env.setdefault('SDL_APP_ID', 'issn')
|
|
if (PORT / 'launcher' / 'issn.png').is_file():
|
|
icon_file = PORT / 'launcher' / 'issn.png'
|
|
try:
|
|
from PIL import Image
|
|
with Image.open(icon_file) as icon:
|
|
icon.convert('RGBA').resize((64, 64), Image.LANCZOS).save(out / 'window_icon.bmp')
|
|
env.setdefault('ISSN_WINDOW_ICON', str(out / 'window_icon.bmp'))
|
|
except (ImportError, OSError):
|
|
pass
|
|
# issn-axy: the shader loading screen shows the dump's own background art (read from the
|
|
# player's game folder at run time, never shipped with the port).
|
|
for art in (game / 'sce_sys' / 'pic1.png', original_game / 'sce_sys' / 'pic1.png'):
|
|
if art.is_file():
|
|
env.setdefault('ISSN_LOADING_ART', str(art))
|
|
break
|
|
# The temporal upscalers and motion vectors are not calibrated for this game yet: they stay
|
|
# off unless the launcher's experimental switch is on (then issn.ini decides).
|
|
if env.get('ISSN_EXPERIMENTAL_UPSCALER') != '1':
|
|
env.setdefault('ISSN_UPSCALER', 'none')
|
|
if profile:
|
|
names = selected_patches(profile[1], profile[2], env.get('ISSN_PATCHES', ''), env.get('ISSN_PATCHES_ONLY') == '1')
|
|
# issn.ini decides the resolution and frame rate patches (in-game menu, launcher); their
|
|
# names are God of War III's (issn: other games keep their patch file's choice).
|
|
if profile[3] == 'God of War III':
|
|
names = graphics_options.patch_names(names, graphics)
|
|
names = graphics_options.restrict_to_available(names, patches.available_patches(profile[1], profile[2]))
|
|
# issn: Internal resolution (the shim's render size) replaces the old fixed-size
|
|
# resolution patches; both would rewrite the same immediates.
|
|
if profile[3] == 'inFAMOUS Second Son' and (env.get('ISSN_RENDER_HEIGHT') or env.get('ISSN_RENDER_SCALE')):
|
|
dropped = [n for n in names if n.startswith('Native ') and '(render and output)' in n]
|
|
if dropped:
|
|
print(f'Patches: {", ".join(dropped)} left out: Internal resolution sets the size', flush=True)
|
|
names = [n for n in names if n not in dropped]
|
|
env['ISSN_PATCHES'] = ';'.join(names)
|
|
env['ISSN_PATCHES_ONLY'] = '1'
|
|
(out / 'patch_status.json').unlink(missing_ok=True)
|
|
run_script('patches.py', '--out', out, '--extra', env.get('ISSN_PATCHES', ''), '--game-dir', game,
|
|
'--patches-dir', env.get('ISSN_PATCHES_DIR', data / 'patches'),
|
|
'--patches-config', env.get('ISSN_PATCHES_CONFIG', data / 'patches.json'))
|
|
if profile:
|
|
# Only the patches found in this eboot.bin count: a frame rate patch that was skipped
|
|
# leaves the engine at 60 FPS, and the notes' memory and VBlank needs follow the same list.
|
|
applied = (patch_status(out) or {}).get('applied', [])
|
|
engine_fps = graphics_options.applied_engine_fps(applied)
|
|
dmem, vblank = patch_requirements(profile[1], applied, profile[2])
|
|
if profile[3] == 'inFAMOUS Second Son':
|
|
dmem = max(dmem or 0, infamous_dmem_mb(env, applied)) or None
|
|
for key, value in (('ISSN_DMEM_MB', dmem), ('ISSN_VBLANK_HZ', vblank)):
|
|
if value and key not in env:
|
|
env[key] = str(value)
|
|
derived.append(key)
|
|
# issn-axy: without ISSN_VBLANK_HZ the GPU library takes the frame rate limit from issn.ini.
|
|
# Without the frame rate patch the engine steps 1/60 s per frame: the present thread must
|
|
# not flip faster (issn_graphics.h PresentLimit), or the game speeds up.
|
|
env['ISSN_ENGINE_FPS'] = str(engine_fps)
|
|
derived.append('ISSN_ENGINE_FPS')
|
|
print(f'Engine frame rate: {engine_fps} FPS'
|
|
+ ('' if engine_fps > 60 or graphics['engine_fps'] == '60' else
|
|
' (the frame rate patch was not found in this eboot.bin)'), flush=True)
|
|
glyph_args, glyph_style = glyph_mounts(env, probe, game, out)
|
|
if glyph_style: # the in-game menu names the buttons the same way
|
|
env['ISSN_GLYPHS'] = glyph_style
|
|
derived.append('ISSN_GLYPHS')
|
|
voice = env.get('ISSN_VOICE', '').strip().lower()
|
|
if voice:
|
|
# inFAMOUS Second Son: the voices of one language under the text of another (the
|
|
# runtime opens lang_<x>_audio.xpps as lang_<voice>_audio.xpps).
|
|
others = ('english', 'dutch', 'french', 'german', 'italian', 'polish', 'portuguese',
|
|
'russian', 'spanish', 'turkish', 'danish', 'finnish', 'norwegian', 'swedish',
|
|
'czech', 'arabic', 'greek')
|
|
aliases = [f'lang_{x}_audio.xpps=lang_{voice}_audio.xpps' for x in others if x != voice]
|
|
if env.get('ISSN_FILE_ALIAS'):
|
|
aliases.insert(0, env['ISSN_FILE_ALIAS'])
|
|
env['ISSN_FILE_ALIAS'] = ';'.join(aliases)
|
|
derived.append('ISSN_FILE_ALIAS')
|
|
print(f'Voices: {voice} (with the text of ISSN_LANGUAGE)', flush=True)
|
|
# issn-axy: FSR 4 Native AA's model (ISSN_FSR4): fsr4_shaders in a package, else the
|
|
# repository's copy.
|
|
if 'ISSN_FSR4_DIR' not in env:
|
|
for candidate in (PORT / 'fsr4_shaders', PORT / 'third_party' / 'fsr4'):
|
|
if (candidate / 'rcas.spv').is_file():
|
|
env['ISSN_FSR4_DIR'] = str(candidate)
|
|
break
|
|
env['ISSN_DERIVED'] = ','.join(derived)
|
|
# The guest memory pool is committed in full at start: a resolution patch above 1080p
|
|
# can need more than 16 GB of RAM, and the game then closes when the page file cannot grow.
|
|
import guest_memory
|
|
if warning := guest_memory.memory_warning(env.get('ISSN_DMEM_MB'), guest_memory.physical_memory_mb(),
|
|
env.get('ISSN_STREAM_MEMORY', 'cached')):
|
|
print(warning, flush=True)
|
|
|
|
# The in-game restart starts this script again once this process is gone.
|
|
# GPU caches (shaders, pipelines) beside the saves; read before main(), so set here.
|
|
user_dir = Path(env.get('ISSN_USER_DIR', data / 'user')).resolve()
|
|
user_dir.mkdir(parents=True, exist_ok=True)
|
|
env.setdefault('ISSN_GPU_USER_DIR', str(user_dir))
|
|
# issn_pc: the shader seed of this package (packaging/windows/package.sh) is the cache folder.
|
|
if (seed_name := shader_seed_name(PORT, user_dir)):
|
|
env.setdefault('SHADPS4_CACHE_NAME', seed_name)
|
|
# The cache folder in the log (10-30: a start on an empty CUSA00223 folder compiled every
|
|
# shader while playing, and nothing in the log said why).
|
|
try:
|
|
listed = (PORT / 'shader-seed.txt').read_text().strip()
|
|
except OSError:
|
|
listed = ''
|
|
cache_name = env.get('SHADPS4_CACHE_NAME', 'CUSA00223')
|
|
others = sorted(p.name for p in (user_dir / 'cache').glob('*') if p.is_dir() and p.name != cache_name)
|
|
print(f'Shader cache: {cache_name}' + ('' if seed_name else
|
|
f" (shader-seed.txt {'names ' + listed + ', which is missing' if listed else 'is missing'})")
|
|
+ (f"; other cache folders: {', '.join(others)}" if others else ''), flush=True)
|
|
# issn 11-04: the game's trophy file (icons for the popup, read from the player's dump) and,
|
|
# with the player's own trophy key, the names (scripts/trophies.py).
|
|
import trophies
|
|
try:
|
|
print(trophies.prepare(env, user_dir), flush=True)
|
|
except OSError as error:
|
|
print(f'Trophies: {error}', flush=True)
|
|
from save_backup import backup
|
|
try:
|
|
if env.get('ISSN_SAVE_BACKUP') == '0':
|
|
print('Save backup: off (launcher)', flush=True)
|
|
elif made := backup(user_dir):
|
|
print(f'Saves backed up to {made}', flush=True)
|
|
except OSError as error:
|
|
print(f'Save backup failed, starting anyway: {error}', flush=True)
|
|
this = ['--run'] if getattr(sys, 'frozen', False) else [str(Path(__file__).resolve())]
|
|
# Windows: the probe starts the new launch and ends, so it waits for this process. Linux:
|
|
# the probe replaces itself with the new launch while this process still waits for it.
|
|
after = ['--after', str(os.getpid())] if WINDOWS else []
|
|
restart = [sys.executable, *this, *after, *args]
|
|
env['ISSN_RESTART_COMMAND'] = subprocess.list2cmdline(restart) if WINDOWS else shlex.join(restart)
|
|
command = [*launch_prefix(env), str(probe), str(out / 'boot-linked.bin'), '--content-profile', str(out / 'content.bin'),
|
|
'--patches', str(out / 'patches.bin'), '--app0', str(game),
|
|
'--user', str(user_dir),
|
|
'--timeout', env.get('ISSN_TIMEOUT', '0'), *isa_args, *glyph_args, *args]
|
|
# No stdin: an inherited pipe (shells such as Git Bash) cost the game its console output.
|
|
if WINDOWS and env.get('ISSN_DEBUGGER') == '1':
|
|
# issn: a minimal debugger reports fast fails, which no handler in the process sees.
|
|
sys.path.insert(0, str(PORT / 'scripts'))
|
|
import windebug
|
|
code = windebug.run(command)
|
|
else:
|
|
code = subprocess.run(command, stdin=subprocess.DEVNULL, creationflags=no_console()).returncode
|
|
report_exit(code)
|
|
return code
|
|
finally:
|
|
if mod_view:
|
|
shutil.rmtree(mod_view, ignore_errors=True)
|
|
if update_view:
|
|
game_update.remove_view(update_view)
|
|
|
|
|
|
def infamous_render_lines(env, applied):
|
|
"""The lines inFAMOUS Second Son renders: Internal resolution (ISSN_RENDER_HEIGHT /
|
|
ISSN_RENDER_SCALE, the shim's render size) or an old "Native WxH" patch, else 1080."""
|
|
try:
|
|
if env.get('ISSN_UPSCALE_FACTOR') and float(env['ISSN_UPSCALE_FACTOR']) >= 1:
|
|
# the window decides (unknown here): a 4K screen, the most it can be
|
|
return max(360, min(2160, round(2160 / float(env['ISSN_UPSCALE_FACTOR']))))
|
|
if env.get('ISSN_RENDER_HEIGHT'):
|
|
return max(360, min(4320, int(env['ISSN_RENDER_HEIGHT'])))
|
|
if env.get('ISSN_RENDER_SCALE'):
|
|
return max(360, min(2160, round(1080 * float(env['ISSN_RENDER_SCALE']) / 100)))
|
|
except ValueError:
|
|
pass
|
|
for name in applied:
|
|
if m := re.match(r'Native (\d+)x(\d+) \(render and output\)', name):
|
|
return int(m.group(2))
|
|
return 1080
|
|
|
|
|
|
def infamous_dmem_mb(env, applied):
|
|
"""Direct memory for inFAMOUS Second Son above 1080 lines (0 at or below). The game gives its
|
|
GPU heap all the direct memory left at boot (3.3 GiB of the retail 5056 MiB); its render
|
|
targets at 4K need about four times their 1080p room, and with the retail pool the picture
|
|
stayed black after the opening poster (RX 7900 XT tester, native 4K patch, 10-24). 4 GiB more
|
|
at 4K (as the shadPS4 notes ask for God of War III's), in proportion to the pixels below."""
|
|
lines = infamous_render_lines(env, applied)
|
|
if lines <= 1080:
|
|
return 0
|
|
extra = 4096 * ((lines / 1080) ** 2 - 1) / 3
|
|
# 16 GiB at most: the probe's pool limit (more is ignored there) and below the guest image,
|
|
# which starts 24 GiB above the pool (5K needs about 13.3 GiB; 8K is held at 16).
|
|
return min(16384, 5056 + int(-(-extra // 256) * 256))
|
|
|
|
|
|
# Windows exit codes of a process that ended without its own report.
|
|
EXIT_CODES = {
|
|
0xC0000005: 'access violation outside the loader\'s handler',
|
|
0xC00000FD: 'stack overflow',
|
|
0xC0000409: 'fast fail (stack buffer overrun check, abort or an invalid parameter)',
|
|
0xC0000374: 'heap corruption',
|
|
0xC000001D: 'illegal instruction',
|
|
0xE0000008: 'out of memory',
|
|
}
|
|
|
|
|
|
def report_exit(code):
|
|
"""Prints the probe's exit code when it is not a normal one (Windows status codes)."""
|
|
status = code & 0xffffffff
|
|
if status in (0, 1, 3, 124) or status < 256:
|
|
return
|
|
print(f'issn-probe ended with status 0x{status:08X}' +
|
|
(f' ({EXIT_CODES[status]})' if status in EXIT_CODES else ''), file=sys.stderr, flush=True)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
try:
|
|
sys.exit(main())
|
|
except KeyboardInterrupt:
|
|
sys.exit(130)
|