Files
Samuel Fontes 6f43d6556f Fix(Window): kept the taskbar from staying over fullscreen (#1021)
EN-US

- Fixed the window being created maximized when the game starts in fullscreen or borderless, which left the Windows taskbar on top
- Added a fallback to borderless fullscreen when exclusive fullscreen fails, and raised the window after switching
- Added AllowSetForegroundWindow(ASFW_ANY) before run.py and the Rust launcher start the game
- Credited Samue1Fontes (#1020)

---

PT-BR

- Corrigida a janela criada maximizada quando o jogo abre em tela cheia ou sem borda, o que deixava a barra de tarefas do Windows por cima
- Adicionado um plano B para tela cheia sem borda quando a tela cheia exclusiva falha, e a janela passa a vir para a frente depois da troca
- Adicionado AllowSetForegroundWindow(ASFW_ANY) antes de o run.py e o launcher Rust iniciarem o jogo
- Creditado Samue1Fontes (#1020)
2026-11-01 12:46:31 -03:00

413 lines
21 KiB
Python

#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-2.0-or-later
"""Prepares the game image and starts spartanport-probe (Windows and Linux; run.sh wraps it on Linux).
Environment variables (SPARTAN_GAME_DIR, SPARTAN_DATA_DIR, SPARTAN_PATCHES, SPARTAN_MODS_*,
SPARTAN_USER_DIR, ...). Extra arguments go to spartanport-probe.
The in-game "Apply and restart" runs this script again through SPARTAN_RESTART_COMMAND;
`--after PID` waits for the previous game process to end first (its GPU device and memory).
"""
import os
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 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 `spartanport-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):
"""SPARTAN_ISA_REWRITE=1 forces the ISA rewrite table, 0 disables it; otherwise the CPU decides."""
policy = env.get('SPARTAN_ISA_REWRITE')
if policy in ('0', '1'):
return policy == '1'
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('SPARTAN_MANGOHUD') == '1':
env.setdefault('MANGOHUD', '1')
if env.get('SPARTAN_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 SPARTAN_GLYPH_STYLE is
auto, xbox, nintendo or keyboard nothing is asked, drawn or mounted, and old copies are removed.
The keyboard keys follow SPARTAN_KEY_MAP; auto shows them when no gamepad is connected. The game
folder is never written."""
choice = env.get('SPARTAN_GLYPH_STYLE', 'off')
if choice not in ('auto', 'xbox', 'nintendo', 'keyboard'):
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('SPARTAN_PAD_MAP', ''), env.get('SPARTAN_KEY_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: a PlayStation 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 switch_text_mounts(env, game, out):
"""Probe arguments for the Weapon Switching tutorials that match the switch attacks: patched
copies of the language WADs (scripts/switch_text.py), only with SPARTAN_SWITCH_ATTACKS=1; without
it old copies are removed. The game folder is never written."""
if env.get('SPARTAN_SWITCH_ATTACKS') != '1':
shutil.rmtree(Path(out) / 'switch_text', ignore_errors=True)
return []
try:
import switch_text
mounts = switch_text.build(game, out)
except Exception as error: # noqa: BLE001 (text only: whatever fails, the game still starts)
print(f'Switch attacks: {error}; the tutorials keep their own text', flush=True)
return []
if mounts:
print(f'Switch attacks: tutorial text on copies in {Path(out) / "switch_text"}; game files untouched',
flush=True)
return [part for guest, path in mounts.items() for part in ('--mount', f'{guest}={path}')]
def pause_menu_args(game, out, mounts, language=None):
"""Probe arguments for the copy of R_UI.WAD with the pause menu's Exit to Desktop row
(scripts/pause_menu.py), made from the glyphs' copy when `mounts` has one. The probe mounts it
over both once its hook is in (src/menu_hook.h). The game folder is never written."""
import pause_menu
guest = '/app0/' + pause_menu.WAD
source = next((part.split('=', 1)[1] for part in mounts if part.startswith(guest + '=')), None)
try:
made = pause_menu.build(game, out, source, language=language)
except OSError as error:
print(f'Pause menu: {error}; no Exit to Desktop row', flush=True)
return []
return [part for path in made.values() for part in ('--pause-menu', str(path))]
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 (SPARTAN_GAME_DIR, SPARTAN_DATA_DIR, fsr4_shaders/) are from the port.
os.chdir(PORT)
env = os.environ
sys.path.insert(0, str(PORT / 'scripts'))
import legacy
legacy.forward_environment(env)
data = Path(env.get('SPARTAN_DATA_DIR', PORT))
legacy.copy_game_settings(data)
out = data / 'out'
out.mkdir(parents=True, exist_ok=True)
env.setdefault('SPARTAN_CONFIG', str(data / 'spartanport.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('SPARTAN_DERIVED', '').split(','):
env.pop(key, None)
derived = []
import controller_mappings
controller_mappings.configure_environment(env, data)
import graphics_options
try:
import discord_presence
except Exception:
discord_presence = None
import mods
import patches
# The enabled mods' patch files: their "Resolution Patch - <size>" entries are render resolutions too
# (the launcher and the in-game menu list them; SPARTAN_EXTRA_RESOLUTIONS tells the game).
mod_patch_files = mods.enabled_patch_files(env.get('SPARTAN_MODS_DIR', data / 'mods'),
env.get('SPARTAN_MODS_CONFIG', data / 'mods.json'),
env.get('SPARTAN_MODS_ENABLED', '1') != '0',
env.get('SPARTAN_GAME_DIR', PORT.parent / 'CUSA01623'))
extra_resolutions = [s for s in patches.resolution_names(mod_patch_files) if s not in graphics_options.RESOLUTIONS]
env['SPARTAN_EXTRA_RESOLUTIONS'] = ';'.join(extra_resolutions)
derived.append('SPARTAN_EXTRA_RESOLUTIONS')
graphics, reverted = graphics_options.prepare(env['SPARTAN_CONFIG'], out / 'graphics_state.json', extra_resolutions, env)
if reverted:
print('Graphics: the last restart never reached the game; previous options restored', flush=True)
env.update(graphics_options.environment(graphics))
# A temporal upscaler preset: the game renders at the preset's size (a resolution patch of that
# size, never the display's) and the upscaler outputs the chosen resolution. Its full-size passes
# are the HUD's.
if upscale := graphics_options.upscaler_render_size(graphics_options.read_ini(env['SPARTAN_CONFIG']), env):
width, height, output = upscale
upscale_patch = out / 'upscale-resolution.xml'
upscale_patch.write_text(graphics_options.resolution_patch_xml(width, height), encoding='utf-8')
mod_patch_files = [*mod_patch_files, upscale_patch]
graphics['render_resolution'] = f'{width}x{height}'
env['SPARTAN_RENDER_RES'] = f'{width}x{height}'
derived.append('SPARTAN_RENDER_RES')
env['SPARTAN_OUTPUT_RES'] = f'{output[0]}x{output[1]}'
derived.append('SPARTAN_OUTPUT_RES')
print(f'Upscaler: render {width}x{height}, output {output[0]}x{output[1]}', flush=True)
if not env.get('SPARTAN_FSR411_DIR') and not (PORT / 'fsr4_411').is_dir() and (data / 'fsr4_411').is_dir():
env['SPARTAN_FSR411_DIR'] = str(data / 'fsr4_411')
probe = Path(env['SPARTAN_PROBE']) if env.get('SPARTAN_PROBE') else find_executable('spartanport-probe' + EXE_SUFFIX)
if not probe or not probe.is_file():
fail(f'spartanport-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('SPARTAN_GAME_DIR', PORT.parent / 'CUSA01623'))
if not (game / 'eboot.bin').is_file():
fail(f'No eboot.bin in {game} (set SPARTAN_GAME_DIR).')
update_view = None
if env.get('SPARTAN_PATCH_DIR'):
import game_update
try:
update_view = game_update.create_view(game, env['SPARTAN_PATCH_DIR'], out)
except (ValueError, OSError) as error:
fail(str(error))
game = update_view
print(f'Game and update combined from {env["SPARTAN_PATCH_DIR"]}', flush=True)
original_game = game.resolve()
game = Path(run_script('mods.py', game, '--out', out,
'--mods-dir', env.get('SPARTAN_MODS_DIR', data / 'mods'),
'--config', env.get('SPARTAN_MODS_CONFIG', data / 'mods.json'),
'--enabled', env.get('SPARTAN_MODS_ENABLED', '1'), capture=True).strip())
mod_view = game if game.resolve() != original_game else None
try:
run_script('prepare.py', game, '--out', out)
run_script('link_libc.py', game, '--out', out)
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('SPARTAN_CONTENT_SKU', 'full'))
from patches import game_profile_for, patch_requirements, patch_status, selected_patches
profile = game_profile_for(game)
engine_fps = 60
# The window shows the port's own icon (BMP: the format SDL loads without extra libraries); on
# Linux also an application id the desktop entry matches, so the shell shows the same icon.
icon_file = PORT / 'launcher' / 'spartanport.png'
if not WINDOWS:
env.setdefault('SDL_APP_ID', 'spartanport')
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('SPARTAN_WINDOW_ICON', str(out / 'window_icon.bmp'))
except (ImportError, OSError):
pass
# 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 spartanport.ini decides); linear
# image readbacks fix its corrupted textures.
if env.get('SPARTAN_EXPERIMENTAL_UPSCALER') != '1':
env.setdefault('SPARTAN_UPSCALER', 'none')
env.setdefault('SPARTAN_READBACK_LINEAR', '1')
patch_xml = profile[1] if profile else None
if profile and mod_patch_files:
# The built-in patches plus the enabled mods' (the same file for the selection and the notes).
patch_xml = patches.merge_patch_files(profile[1], mod_patch_files, out / 'patches-merged.xml')
if profile:
names = selected_patches(patch_xml, profile[2], env.get('SPARTAN_PATCHES', ''), env.get('SPARTAN_PATCHES_ONLY') == '1')
# A mod's patches that are on by default (isEnabled) stay on when the launcher chose the list.
names += [n for n in patches.default_enabled(mod_patch_files, profile[2]) if n not in names]
# spartanport.ini decides the resolution and frame rate patches (in-game menu, launcher).
names = graphics_options.patch_names(names, graphics)
names = graphics_options.restrict_to_available(names, patches.available_patches(patch_xml, profile[2]))
env['SPARTAN_PATCHES'] = ';'.join(names)
env['SPARTAN_PATCHES_ONLY'] = '1'
(out / 'patch_status.json').unlink(missing_ok=True)
run_script('patches.py', '--out', out, '--extra', env.get('SPARTAN_PATCHES', ''), '--game-dir', game,
'--patches-dir', env.get('SPARTAN_PATCHES_DIR', data / 'patches'),
'--patches-config', env.get('SPARTAN_PATCHES_CONFIG', data / 'patches.json'),
*[arg for f in mod_patch_files for arg in ('--mod-patches', f)])
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(patch_xml, applied, profile[2])
for key, value in (('SPARTAN_DMEM_MB', dmem), ('SPARTAN_VBLANK_HZ', vblank)):
if value and key not in env:
env[key] = str(value)
derived.append(key)
if 'SPARTAN_VBLANK_HZ' not in env:
env['SPARTAN_VBLANK_HZ'] = '60'
derived.append('SPARTAN_VBLANK_HZ')
# The highest engine rate this launch allows (the menu picks a lower one live). Without the
# frame rate patch the engine steps 1/60 s per frame: the present thread must not flip
# faster (spartanport_graphics.h PresentLimit), or the game speeds up.
env['SPARTAN_ENGINE_FPS'] = str(engine_fps)
derived.append('SPARTAN_ENGINE_FPS')
print(f'Engine frame rate: {min(int(graphics["engine_fps"]), engine_fps)} FPS, live up to {engine_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['SPARTAN_GLYPHS'] = glyph_style
derived.append('SPARTAN_GLYPHS')
mount_args = (glyph_args + pause_menu_args(game, out, glyph_args, env.get('SPARTAN_LANGUAGE'))
+ switch_text_mounts(env, game, out))
env['SPARTAN_DERIVED'] = ','.join(derived)
# The guest memory pool is committed at start, or as the game maps it when Windows refuses:
# 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('SPARTAN_DMEM_MB'), guest_memory.physical_memory_mb(),
env.get('SPARTAN_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('SPARTAN_USER_DIR', data / 'user')).resolve()
user_dir.mkdir(parents=True, exist_ok=True)
env['SPARTAN_USER_DIR'] = str(user_dir)
env.setdefault('SPARTAN_GPU_USER_DIR', str(user_dir))
from save_backup import backup
try:
if 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['SPARTAN_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('SPARTAN_TIMEOUT', '0'), *isa_args, *mount_args, *args]
# No stdin: an inherited pipe (shells such as Git Bash) cost the game its console output.
if WINDOWS:
ctypes.windll.user32.AllowSetForegroundWindow(-1)
process = subprocess.Popen(command, stdin=subprocess.DEVNULL, creationflags=no_console())
presence = None
try:
try:
presence = discord_presence.Presence(pid=process.pid) if discord_presence else None
except (OSError, RuntimeError):
print('Discord presence unavailable; the game will continue', flush=True)
return process.wait()
finally:
if presence:
presence.close()
finally:
if mod_view:
shutil.rmtree(mod_view, ignore_errors=True)
if update_view:
game_update.remove_view(update_view)
if __name__ == '__main__':
try:
sys.exit(main())
except KeyboardInterrupt:
sys.exit(130)