Compare commits

...
7 changed files with 572 additions and 121 deletions

No files matched your search

@@ -35,7 +35,7 @@ object SessionFiles {
"usr/local/bin/bannerlator-steam-compat" to "usr/local/bin/bannerlator-steam-compat",
"usr/local/bin/bannerlator-steam-install" to "usr/local/bin/bannerlator-steam-install",
"usr/local/bin/bannerlator-steam-library" to "usr/local/bin/bannerlator-steam-library",
"usr/local/bin/bannerlator-seed-redists" to "usr/local/bin/bannerlator-seed-redists",
"usr/local/bin/bannerlator-restore-redists" to "usr/local/bin/bannerlator-restore-redists",
"usr/local/bin/bannerlator-proton-extra" to "usr/local/bin/bannerlator-proton-extra",
"usr/local/bin/bannerlator-netmanager" to "usr/local/bin/bannerlator-netmanager",
"usr/local/bin/bannerlator-steam-launch" to "usr/local/bin/bannerlator-steam-launch",
@@ -0,0 +1,86 @@
#!/usr/bin/python3
"""Undo the old blanket HasRunKey seeder before Steam/Wine starts.
Steam must run native prerequisites and record their real result. Only prefixes carrying
our old seeder stamp are migrated. Proton's intentional .NET/XNA defaults remain intact.
"""
import os
from pathlib import Path
import re
import sys
import tempfile
def restore_prefix(prefix):
prefix = Path(prefix)
stamp = prefix / '.bannerlator-redists'
reg = prefix / 'system.reg'
if not stamp.is_file() or stamp.is_symlink() or not reg.is_file() or reg.is_symlink():
return False
text = reg.read_text(encoding='utf-8')
if not text.startswith('WINE REGISTRY Version 2'):
raise ValueError('not a Wine registry')
out = []
clear = False
for line in text.splitlines(keepends=True):
match = re.match(r'^\[([^\]]+)\]', line)
if match:
key = match[1].replace('\\\\', '\\').lower()
parts = key.split('\\')
try:
common = parts.index('commonredist')
except ValueError:
clear = False
else:
clear = (parts[:common] in (
['software', 'valve', 'steam', 'apps'],
['software', 'wow6432node', 'valve', 'steam', 'apps'])
and len(parts) > common + 1
and parts[common + 1] not in ('.net', 'xna'))
if clear and re.fullmatch(r'"[^"\r\n]+"=dword:00000001\s*', line):
continue
out.append(line)
updated = ''.join(out)
if updated != text:
stat = reg.stat()
descriptor, temporary = tempfile.mkstemp(dir=prefix, prefix='.redists-')
try:
with os.fdopen(descriptor, 'w', encoding='utf-8') as f:
f.write(updated)
os.chmod(temporary, stat.st_mode & 0o777)
# Preserve template dates: this migration already covers existing prefixes and
# should not force every game through a Proton prefix upgrade.
os.utime(temporary, ns=(stat.st_atime_ns, stat.st_mtime_ns))
os.replace(temporary, reg)
finally:
if os.path.exists(temporary):
os.unlink(temporary)
stamp.unlink()
return updated != text
def restore(steam_root, libraries=('/mnt/droiddeck-sd',)):
roots = [Path(steam_root), *(Path(p) for p in libraries)]
prefixes = []
for root in roots:
prefixes.extend(root.glob('steamapps/compatdata/*/pfx'))
prefixes.extend(root.glob('steamapps/common/Proton*/files/share/default_pfx*'))
prefixes.extend(Path(steam_root).glob('compatibilitytools.d/*/files/share/default_pfx*'))
seen = set()
changed = 0
for prefix in prefixes:
actual = prefix.resolve()
if actual in seen:
continue
seen.add(actual)
try:
if restore_prefix(actual):
changed += 1
print('bannerlator-restore-redists: restored Steam prerequisite checks in %s' % prefix)
except (OSError, UnicodeError, ValueError) as error:
print('bannerlator-restore-redists: %s: %s' % (prefix, error), file=sys.stderr)
return changed
if __name__ == '__main__':
restore(sys.argv[1])
@@ -1,94 +0,0 @@
#!/bin/bash
# Marks Steam's shared redistributables as already run in every Proton prefix, and in the
# template Proton makes new prefixes from.
#
# A game whose depot lists _CommonRedist makes the client run the real installer inside the
# prefix on first launch. Those are x86 bundles, so they run under FEX, and the 2019 one leaves
# a task behind every time: its main thread exits while a helper stays blocked reading a pipe it
# also holds the write end of, so the process never reaps and the client keeps waiting on it.
#
# The client skips a "Run Process" block whose HasRunKey is already set, which is the same escape
# WineUtils.seedVcRedistRegistryIfDllsPresent() takes for the app's own containers. The names and
# versions come out of the install scripts rather than a list kept here, because Valve revises
# them; a prefix already carrying the same set is left alone.
set -u
steam_root=${1:?steam root}
redist="$steam_root/steamapps/common/Steamworks Shared/_CommonRedist"
# Every process an install script would run, as the registry key and value the client writes once
# it has run it. Names sit one level inside "Run Process", the key they share a level further in.
pairs=$(find "$redist" -name installscript.vdf -print0 2>/dev/null | sort -z | xargs -0 -r awk '
FNR == 1 { depth = 0; name = ""; key = "" }
/^[[:space:]]*\{/ { depth++; next }
/^[[:space:]]*\}/ {
if (depth == 3 && name != "" && key != "")
printf "%s\t%s\n", key, name
depth--
next
}
{
n = split($0, part, "\"")
if (n >= 5 && tolower(part[2]) == "hasrunkey") { key = part[4]; next }
if (n >= 3 && depth == 2) { name = part[2]; key = "" }
}
')
# The client downloads the scripts with the first game that wants them, which is too late for that
# game; until then the set they held when this was written stands in. (WinNative's list.)
[ -n "$pairs" ] || pairs=$(sed 's/^\([^ ]*\) /HKEY_LOCAL_MACHINE\\\\Software\\\\Valve\\\\Steam\\\\Apps\\\\CommonRedist\\\\\1\t/' <<'KNOWN'
DirectX\\Jun2010 dxsetup
.NET\\4.8 4.8
vcredist\\2008 x86 SP1
vcredist\\2008 x64 SP1
vcredist\\2010 x86
vcredist\\2010 x64
vcredist\\2013 x86 12.0.30501
vcredist\\2013 x64 12.0.30501
vcredist\\2015 x86 Update 3 14.0.24215.0
vcredist\\2015 x64 Update 3 14.0.24215.0
vcredist\\2017 x86 14.10.25008.0
vcredist\\2017 x64 14.10.25008.0
vcredist\\2019 x86 14.28.29334.0
vcredist\\2019 x64 14.28.29334.0
vcredist\\2022 x86 14.51.36247.0
vcredist\\2022 x64 14.51.36247.0
KNOWN
)
# The stamp is the set of names, taken before the block below dates its keys: stamped from the
# dated block it never matched, and every prefix had the whole block appended again on every beat.
stamp=$(printf '%s' "$pairs" | cksum)
block=$(printf '%s\n' "$pairs" | awk -F'\t' -v now="$(date +%s)" '
{
path = $1
sub(/^HKEY_LOCAL_MACHINE\\\\/, "", path)
if (path == $1)
next
printf "\n[%s] %d\n\"%s\"=dword:00000001\n", path, now, $2
# The client writes these from 32-bit steam.exe, so its own copy lands under Wow6432Node;
# both spellings are seeded because the reader picks by its own word size.
sub(/^Software\\\\/, "Software\\\\\\\\Wow6432Node\\\\\\\\", path)
printf "\n[%s] %d\n\"%s\"=dword:00000001\n", path, now, $2
}
')
[ -n "$block" ] || exit 0
# Proton builds a new prefix from its template, and the client runs the install scripts in that
# prefix moments later, so the template carries the markers too. Its date is kept: Proton reads a
# changed one as a new template and upgrades every prefix made from the old.
for reg in "$steam_root"/steamapps/common/Proton*/files/share/default_pfx*/system.reg \
"$steam_root"/compatibilitytools.d/*/files/share/default_pfx*/system.reg \
"$steam_root"/steamapps/compatdata/*/pfx/system.reg \
/mnt/droiddeck-sd/steamapps/compatdata/*/pfx/system.reg; do
[ -f "$reg" ] || continue
seeded=$(dirname "$reg")/.bannerlator-redists
[ "$(cat "$seeded" 2>/dev/null)" = "$stamp" ] && continue
dated=$(mktemp) || continue
touch -r "$reg" "$dated"
if printf '%s\n' "$block" >>"$reg"; then
printf '%s\n' "$stamp" >"$seeded"
echo "== seeded redist markers into $reg"
fi
touch -r "$dated" "$reg"
rm -f "$dated"
done
@@ -323,9 +323,9 @@ case $mode in
step "adding the app's own games to the client's library"
bannerlator-steam-shortcuts "$steam_root" "$BL_ADDED_GAMES" || true
fi
# Steam runs a game's shared redistributables inside its prefix on first launch; marking
# them as already run keeps those x86 installers from starting under emulation at all.
bannerlator-seed-redists "$steam_root" || true
# Undo earlier blanket success markers while Wine is stopped. Steam now installs its
# prerequisites normally; the Proton wrapper handles the old VC MSI bundles quietly.
bannerlator-restore-redists "$steam_root" || true
# The compatibility tool runs Valve's arm64 Proton, which the client fetches like any depot.
# Asked for on the command line the first time, so a Windows title has it before it is needed.
if [ ! -f "$steam_root/steamapps/appmanifest_4427310.acf" ]; then
@@ -397,18 +397,6 @@ case $mode in
# titles are installed: a title installed this session gets the client's own per-app Proton
# choice, which it cannot start. Refresh the per-app entries every so often; the registrar is
# idempotent, so a repeat costs nothing.
# The seeder has the same problem and a worse consequence. It marks the shared redistributables
# as already run in each prefix that exists, and a game's prefix is not created until the
# client launches it - so a title played for the first time this session was never covered, and
# its install scripts run for real. That is exactly the hang the seeder exists to prevent: Need
# for Speed Payback sat on "Running install script" with vcredist_x86.exe blocked in pipe_read,
# holding both ends of a pipe nobody would write to, while the client waited for it to exit.
# Seeding again on the same beat covers a prefix the moment it appears.
# The seeder runs on a shorter beat than the registrar because it is racing something. The
# client creates a prefix when it launches a title and runs that title's install scripts
# moments later, so the seeder has only that gap to mark the redistributables as already run.
# It stamps each prefix it has covered and skips it thereafter, so the extra passes cost
# almost nothing.
# The drawer's "Stretch games to fill the screen" works live: the app writes its choice to
# ~/.droiddeck-fill, and each change goes to gamescope as GAMESCOPE_FORCE_WINDOWS_FULLSCREEN on
# the root window, which it watches. What gamescope was started with is where this begins.
@@ -478,13 +466,8 @@ case $mode in
esac
done
) &
# The library beat. Both helpers are whole programs under proot (the compat one a python
# start and a parse of the client's config), and nearly every beat has nothing new for them.
# The seeder marks Proton's prefix template as well as the prefixes, so a new prefix is born
# marked and every 15 s is soon enough for the rest. The compat pass runs when a directory it
# reads from has changed since its last run - a new manifest, a new depot or tool, the client
# rewriting config.vdf - which [ -nt ] answers without starting anything, and once a minute
# regardless.
# Refresh compatibility registrations when a library or client config changes, and once
# a minute regardless. Prerequisite checks belong to Steam and are not changed on this beat.
(
beat=0
compat_stamp=$HOME/.bl-compat-beat
@@ -502,7 +485,6 @@ case $mode in
nap 5
beat=$((beat + 1))
[ "$((beat % 3))" -eq 0 ] || continue
bannerlator-seed-redists "$steam_root" >/dev/null 2>&1 || true
if compat_due; then
: > "$compat_stamp"
bannerlator-steam-compat "$steam_root" >/dev/null 2>&1 || true
@@ -16,9 +16,12 @@
# require_tool_appid. Proton takes its base directory from dirname(sys.argv[0]) without resolving
# it, so it reads files/ through those symlinks and runs directly on this rootfs.
import os
import hashlib
import re
import shutil
import subprocess
import sys
import tempfile
TOOL = "bannerlator-proton-arm64"
# Valve's own ARM64 Proton, by the name the client registers it under. Used only to bootstrap a
@@ -141,6 +144,18 @@ bl_game_cpus() {
}
"""
# Prepare Steam's transient evaluator input before Wine reads it. Recognized legacy VC bundles
# use their actual MSI; a verified legacy PhysX bundle uses its supported quiet argument.
BL_INSTALLSCRIPT_SETUP = """
bl_installscript() {
[ "${verb:-}" = run ] || return 0
case "${1:-}" in */iscriptevaluator.exe)
/usr/local/bin/bannerlator-steam-compat --installscript "${STEAM_COMPAT_CLIENT_INSTALL_PATH:-}" "${2:-}"
;;
esac
}
"""
# Quake III engine games (id Tech 3) set their own resolution and switch the display to it. Under
# gamescope with the ARM64 Proton that switch is refused ("calling CDS: failed, bad mode") and the
# game never gets OpenGL; left at its own size in a window, it draws small in a corner. So these
@@ -272,6 +287,7 @@ if [ -n "$depot" ]; then
bl_game_cpus
bl_idtech3 "$@"
bl_libpath
bl_installscript "$@"
exec ${BL_TASKSET:-} /usr/local/bin/bannerlator-game-env "$depot/proton" "$verb" "$@" "${BL_EXTRA_ARGS[@]}"
fi
echo "bannerlator-proton: no ARM64 Proton depot yet - the client downloads one as this tool's dependency; retry the launch once it has" >&2
@@ -282,7 +298,7 @@ exit 1
# something else.
LAUNCHER_SH = LAUNCHER_SH.replace(
"verb=$1; shift", "verb=$1; shift\n" + BL_DIRECTAUDIO_SETUP + BL_GAME_CPUS_SETUP + BL_IDTECH3_SETUP
+ BL_COMPONENTS_SETUP + BL_LIBPATH_SETUP, 1)
+ BL_COMPONENTS_SETUP + BL_LIBPATH_SETUP + BL_INSTALLSCRIPT_SETUP, 1)
# A third-party Proton dropped into compatibilitytools.d - GE-Proton, proton-cachyos, anything of
# that shape - is adopted rather than used as shipped, for the same two reasons Valve's ARM64 depot
@@ -319,6 +335,7 @@ bl_directaudio "$here"
bl_game_cpus
bl_idtech3 "$@"
bl_libpath
bl_installscript "$@"
# Proton takes its base directory from dirname(sys.argv[0]) without resolving it, so it has to be
# started from its own tree.
exec ${BL_TASKSET:-} /usr/local/bin/bannerlator-game-env "$here/%s" "$verb" "$@" "${BL_EXTRA_ARGS[@]}"
@@ -430,6 +447,188 @@ def write_changed(path, text, mode=None):
return not same
def vdf_value(token):
return re.sub(r'\\(["\\])', r'\1', token[1:-1])
def vdf_quote(value):
return '"' + value.replace('\\', '\\\\').replace('"', '\\"') + '"'
def direct_entries(tokens, start):
"""Yield keys and their value/block indices, preserving repeated VDF keys."""
i = start + 1
while i < block_end(tokens, start):
yield vdf_value(tokens[i]).lower(), i + 1
i = block_end(tokens, i + 1) + 1 if tokens[i + 1] == '{' else i + 2
def redist_msi(installer, cache_root):
"""Extract a recognized old VC x86 MSI bundle with the runtime's libarchive tools.
Darksiders' VC2005 IExpress child exits its main thread under ARM64EC Wine, leaving another
thread waiting on a pipe. Steam waits forever for that child. Running its actual MSI directly
succeeds. Burn bundles and other installers keep their original launch path.
"""
def members(path):
result = subprocess.run(['bsdtar', '-tf', path], check=True, capture_output=True,
text=True, timeout=10)
return {name.lower(): name for name in result.stdout.splitlines()
if name and '/' not in name and '\\' not in name and name not in ('.', '..')}
def extract(source, names, destination):
subprocess.run(['bsdtar', '-xf', source, '-C', destination,
'--no-same-owner', '--no-same-permissions', '--', *names],
check=True, capture_output=True, timeout=10)
for name in names:
path = os.path.join(destination, name)
if os.path.islink(path) or not os.path.isfile(path):
raise ValueError('redistributable member is not a regular file')
# These pre-Burn bundles are small. Avoid interpreting an arbitrary large executable archive.
if os.path.getsize(installer) > 32 * 1024 * 1024:
return None
with open(installer, 'rb') as f:
hasher = hashlib.sha256()
for chunk in iter(lambda: f.read(65536), b''):
hasher.update(chunk)
digest = hasher.hexdigest()
target = os.path.join(cache_root, digest)
os.makedirs(cache_root, exist_ok=True)
with tempfile.TemporaryDirectory(dir=cache_root) as staging:
source = installer
entries = members(source)
# VC2005 has two IExpress layers. Extract only the recognized flat child, never the
# archive's full contents; libarchive's CAB support also handles these embedded cabinets.
inner = [name for key, name in entries.items()
if re.fullmatch(r'vcredi[^/\\]*\.exe', key)]
if len(inner) == 1:
extract(source, inner, staging)
source = os.path.join(staging, inner[0])
entries = members(source)
for msi, cab in (('vcredist.msi', 'vcredis1.cab'), ('vc_red.msi', 'vc_red.cab')):
if msi in entries and cab in entries:
payload = os.path.join(staging, 'payload')
os.mkdir(payload)
names = [entries[msi], entries[cab]]
extract(source, names, payload)
with open(os.path.join(payload, names[0]), 'rb') as f:
if f.read(8) != b'\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1':
raise ValueError('redistributable MSI has an invalid header')
with open(os.path.join(payload, names[1]), 'rb') as f:
if f.read(4) != b'MSCF':
raise ValueError('redistributable cabinet has an invalid header')
# Reuse only a complete pair. A failed extraction never becomes the cache entry.
if not all(os.path.isfile(os.path.join(target, n)) and
not os.path.islink(os.path.join(target, n)) for n in names):
if os.path.lexists(target):
raise ValueError('incomplete redistributable cache')
os.rename(payload, target)
return os.path.join(target, names[0])
return None
# Mirror's Edge bundles Wise's PhysX 8.10.17 bootstrapper without arguments. /s is forwarded to
# msiexec and still opens its license wizard; /qn installs the actual runtime silently. Match the
# exact tested bundle, rather than guessing switches for other PhysX installer generations.
QUIET_PHYSX_SHA256 = '32d27a5e64e099adc4d3d2c87e5702f237c3d1292406f166059837c4c827e22f'
def quiet_physx(installer):
if os.path.getsize(installer) > 64 * 1024 * 1024 or not os.path.isfile(installer):
return False
digest = hashlib.sha256()
with open(installer, 'rb') as f:
for chunk in iter(lambda: f.read(65536), b''):
digest.update(chunk)
return digest.hexdigest() == QUIET_PHYSX_SHA256
def prepare_installscript(steam_root, script):
# The input is Steam's temporary evaluator file, not a game's installed metadata. Restrict
# resolution to that one directory so a Windows path or a traversal cannot rewrite other files.
parts = script.replace('\\', '/').split('/')
if len(parts) != 2 or parts[0].lower() != 'legacycompat' or not re.fullmatch(
r'evaluatorscript_[0-9]+\.vdf', parts[1], re.IGNORECASE):
return False
if not os.path.isabs(steam_root):
return False
path = os.path.join(steam_root, 'legacycompat', parts[1])
with open(path, encoding='utf-8') as f:
tokens = tokenize(f.read())
root = find_block(tokens, ['evaluatorscript'])
if root is None:
return False
changed = False
for _, entry in direct_entries(tokens, root):
if tokens[entry] != '{':
continue
values = dict(direct_entries(tokens, entry))
install_index = values.get('install_path')
compat = values.get('compat_installscript')
if install_index is None or compat is None or tokens[compat] != '{':
continue
install_dir = vdf_value(tokens[install_index])
run = dict(direct_entries(tokens, compat)).get('run process')
if run is None or tokens[run] != '{' or not os.path.isabs(install_dir):
continue
for _, process in direct_entries(tokens, run):
if tokens[process] != '{':
continue
fields = dict(direct_entries(tokens, process))
exe_index, args_index = fields.get('process 1'), fields.get('command 1')
if exe_index is None:
continue
match = re.fullmatch(r'%INSTALLDIR%[\\/]+(.+)',
vdf_value(tokens[exe_index]), re.IGNORECASE)
args = vdf_value(tokens[args_index]).split() if args_index is not None else []
if not match:
continue
relative = re.split(r'[\\/]+', match[1])
physx = relative[-1].lower() == 'physx_systemsoftware.exe' and not args
vc = re.fullmatch(r'vcredist_x86[^\\/]*\.exe', relative[-1], re.IGNORECASE)
if any(part in ('', '.', '..') for part in relative) or not (physx or (vc and args and
all(arg.lower() in ('/q:a', '/q', '/qn', '/quiet', '/norestart') for arg in args))):
continue
try:
installer = install_dir
# Wine resolves filenames without case sensitivity, even on the private library.
for part in relative:
if not os.path.exists(os.path.join(installer, part)):
matches = [n for n in os.listdir(installer) if n.lower() == part.lower()]
if len(matches) != 1:
raise ValueError('ambiguous or missing redistributable path')
part = matches[0]
installer = os.path.join(installer, part)
if physx:
if not quiet_physx(installer):
continue
if args_index is None:
end = block_end(tokens, process)
tokens[end:end] = [vdf_quote('command 1'), vdf_quote('/qn')]
else:
tokens[args_index] = vdf_quote('/qn')
changed = True
print('bannerlator-steam-compat: running verified PhysX 8.10.17 quietly',
file=sys.stderr)
continue
msi = redist_msi(installer,
os.path.join(steam_root, 'legacycompat', '.droiddeck-redists'))
except (OSError, ValueError, subprocess.SubprocessError) as e:
print('bannerlator-steam-compat: redist preparation failed; original installer kept: %s' % e,
file=sys.stderr)
continue
if msi is None:
continue
tokens[exe_index] = vdf_quote(r'C:\windows\syswow64\msiexec.exe')
tokens[args_index] = vdf_quote('/i "Z:%s" /qn /norestart' % msi.replace('/', '\\'))
changed = True
print('bannerlator-steam-compat: running extracted MSI for %s' % match[1], file=sys.stderr)
if changed:
write_changed(path, serialize(tokens))
return changed
def first_key(path, section):
"""The single key naming a block inside `section`, or None. Used to keep a third-party tool's
own internal name, so a mapping the user already made against it keeps working."""
@@ -503,7 +702,7 @@ def adopt_extras(tools):
body = (EXTRA_WRAPPER_SH % entry_point).replace(
'here=$(dirname "$0")',
'here=$(dirname "$0")\n' + BL_DIRECTAUDIO_SETUP + BL_GAME_CPUS_SETUP + BL_IDTECH3_SETUP
+ BL_COMPONENTS_SETUP + BL_LIBPATH_SETUP, 1)
+ BL_COMPONENTS_SETUP + BL_LIBPATH_SETUP + BL_INSTALLSCRIPT_SETUP, 1)
write_changed(wrapper, body, 0o755)
write_changed(manifest, EXTRA_MANIFEST_VDF)
write_changed(compat_vdf, EXTRA_COMPAT_TOOL_VDF % (name, entry))
@@ -638,4 +837,10 @@ def main():
if __name__ == "__main__":
main()
if len(sys.argv) == 4 and sys.argv[1] == '--installscript':
try:
prepare_installscript(sys.argv[2], sys.argv[3])
except (OSError, ValueError, IndexError) as e:
print('bannerlator-steam-compat: original install script kept: %s' % e, file=sys.stderr)
else:
main()
+90
View File
@@ -0,0 +1,90 @@
from pathlib import Path
import runpy
import tempfile
import unittest
RESTORE = runpy.run_path(str(Path(__file__).resolve().parents[1] /
'linuxfs/overlay/usr/local/bin/bannerlator-restore-redists'))
class RestoreRedistsTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.root = Path(self.tmp.name)
def prefix(self, path):
path.mkdir(parents=True)
reg = path / 'system.reg'
reg.write_text('WINE REGISTRY Version 2\n\n' +
'[Software\\\\Wow6432Node\\\\Valve\\\\Steam\\\\Apps\\\\CommonRedist\\\\vcredist\\\\2019] 1\n'
'"x86"=dword:00000001\n"x64"=dword:00000001\n"OtherData"="preserved"\n\n'
'[Software\\\\Valve\\\\Steam\\\\Apps\\\\CommonRedist\\\\DirectX\\\\Jun2010] 1\n'
'"dxsetup"=dword:00000001\n\n'
'[Software\\\\Wow6432Node\\\\Valve\\\\Steam\\\\Apps\\\\CommonRedist\\\\.NET\\\\4.8] 1\n'
'"4.8"=dword:00000001\n\n'
'[Software\\\\Wow6432Node\\\\Valve\\\\Steam\\\\Apps\\\\CommonRedist\\\\XNA\\\\4.0] 1\n'
'"4.0"=dword:00000001\n\n'
'[Software\\\\Valve\\\\Steam\\\\Apps\\\\50620] 1\n'
'"Accessories"=dword:00000001\n\n'
'[Software\\\\Microsoft\\\\Installer\\\\Products\\\\NativeProduct] 1\n'
'"Installed"=dword:00000001\n')
(path / '.bannerlator-redists').write_text('old checksum')
return reg
def test_migration_preserves_proton_defaults_native_products_and_game_checks(self):
reg = self.prefix(self.root / 'pfx')
stat = reg.stat()
self.assertTrue(RESTORE['restore_prefix'](reg.parent))
text = reg.read_text()
for flag in ('"x86"', '"x64"', '"dxsetup"'):
self.assertNotIn(flag + '=dword:00000001', text)
for flag in ('"4.8"', '"4.0"', '"Accessories"', '"Installed"'):
self.assertIn(flag + '=dword:00000001', text)
self.assertIn('"OtherData"="preserved"', text)
self.assertEqual(stat.st_mtime_ns, reg.stat().st_mtime_ns)
self.assertEqual(stat.st_mode, reg.stat().st_mode)
# Once Steam has really installed a prerequisite, a later startup keeps its result.
with reg.open('a') as f:
f.write('\n[Software\\\\Valve\\\\Steam\\\\Apps\\\\CommonRedist\\\\vcredist\\\\2022] 2\n'
'"x86"=dword:00000001\n')
installed = reg.read_bytes()
self.assertFalse(RESTORE['restore_prefix'](reg.parent))
self.assertEqual(installed, reg.read_bytes())
def test_unstamped_prefixes_and_registry_symlinks_are_untouched(self):
reg = self.prefix(self.root / 'pfx')
original = reg.read_bytes()
(reg.parent / '.bannerlator-redists').unlink()
self.assertFalse(RESTORE['restore_prefix'](reg.parent))
self.assertEqual(original, reg.read_bytes())
(reg.parent / '.bannerlator-redists').touch()
outside = self.root / 'original.reg'
reg.rename(outside)
reg.symlink_to(outside)
self.assertFalse(RESTORE['restore_prefix'](reg.parent))
self.assertEqual(original, outside.read_bytes())
def test_primary_secondary_and_both_template_architectures_are_migrated(self):
steam, secondary = self.root / 'Steam', self.root / 'SD'
paths = [steam / 'steamapps/compatdata/1/pfx',
secondary / 'steamapps/compatdata/2/pfx',
steam / 'steamapps/common/Proton 11/files/share/default_pfx_arm64',
secondary / 'steamapps/common/Proton Experimental/files/share/default_pfx',
steam / 'compatibilitytools.d/GE/files/share/default_pfx_arm64']
for path in paths:
self.prefix(path)
self.assertEqual(5, RESTORE['restore'](steam, [secondary]))
self.assertEqual(0, RESTORE['restore'](steam, [secondary]))
def test_invalid_registry_keeps_migration_stamp_for_retry(self):
reg = self.prefix(self.root / 'pfx')
reg.write_text('incomplete registry')
with self.assertRaises(ValueError):
RESTORE['restore_prefix'](reg.parent)
self.assertTrue((reg.parent / '.bannerlator-redists').is_file())
self.assertEqual('incomplete registry', reg.read_text())
if __name__ == '__main__':
unittest.main()
+182
View File
@@ -0,0 +1,182 @@
from pathlib import Path
import hashlib
import runpy
import tempfile
import unittest
from unittest.mock import patch
COMPAT = runpy.run_path(str(Path(__file__).resolve().parents[1] /
'linuxfs/overlay/usr/local/bin/bannerlator-steam-compat'))
PREPARE = COMPAT['prepare_installscript']
class SteamRedistTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.steam = Path(self.tmp.name) / 'Steam root'
(self.steam / 'legacycompat').mkdir(parents=True)
self.path = self.steam / 'legacycompat/evaluatorscript_50620.vdf'
self.msi = str(self.steam / 'legacycompat/cache/vcredist.msi')
self.installer_dir = self.steam / 'steamapps/common/Darksiders'
self.installer_dir.mkdir(parents=True)
(self.installer_dir / 'vcredist_x86_sp1_atl_4053.exe').touch()
self.original = '''"evaluatorscript" { "0" {
"appid" "50620" "install_path" "%s"
"compat_installscript" {
"registry" { "unrelated" { "string" { "keep" "value" } } }
"RuN PrOcEsS" {
"VCREDIST" { "Process 1" "%%INSTALLDIR%%\\\\vcredist_x86_sp1_atl_4053.exe"
"Command 1" "/q:a" "IgnoreExitCode" "1" "NoCleanUp" "1" }
"Accessories" { "process 1" "%%INSTALLDIR%%\\\\DSInstaller.exe"
"command 1" "" "description" "Comic and Soundtrack" }
}
}
} }''' % (self.steam / 'steamapps/common/Darksiders')
self.path.write_text(self.original)
def prepare(self):
return PREPARE(str(self.steam), r'legacycompat\evaluatorscript_50620.vdf')
def test_msi_launch_preserves_other_install_steps_and_registry(self):
with patch.dict(PREPARE.__globals__, redist_msi=lambda *args: self.msi):
self.assertTrue(self.prepare())
tokens = COMPAT['tokenize'](self.path.read_text())
decoded = [COMPAT['vdf_value'](t) for t in tokens if t.startswith('"')]
self.assertIn(r'C:\windows\syswow64\msiexec.exe', decoded)
self.assertIn('/i "Z:%s" /qn /norestart' % self.msi.replace('/', '\\'), decoded)
self.assertIn(r'%INSTALLDIR%\DSInstaller.exe', decoded)
self.assertIn('Comic and Soundtrack', decoded)
self.assertIn('unrelated', decoded)
self.assertIn('IgnoreExitCode', decoded)
# Once converted, another wrapper invocation leaves the file untouched.
before = self.path.read_bytes()
self.assertFalse(self.prepare())
self.assertEqual(before, self.path.read_bytes())
def test_uninstall_custom_commands_and_other_architectures_are_untouched(self):
for old, new in (('/q:a', '/uninstall /quiet'), ('/q:a', '/q:a /custom'),
('vcredist_x86', 'vcredist_x64'),
('vcredist_x86_sp1_atl_4053.exe', 'other.exe')):
self.path.write_text(self.original.replace(old, new))
before = self.path.read_bytes()
with patch.dict(PREPARE.__globals__, redist_msi=lambda *args: self.fail('not a supported installer')):
self.assertFalse(self.prepare())
self.assertEqual(before, self.path.read_bytes())
def test_failed_or_unsupported_extraction_keeps_original(self):
def fail(*args):
raise OSError('missing archive tool')
for resolver in (fail, lambda *args: None):
with patch.dict(PREPARE.__globals__, redist_msi=resolver):
self.assertFalse(self.prepare())
self.assertEqual(self.original, self.path.read_text())
def test_only_transient_evaluator_paths_can_be_rewritten(self):
for path in ('../legacycompat/evaluatorscript_50620.vdf',
str(self.path), r'legacycompat\other.vdf',
r'legacycompat\..\evaluatorscript_50620.vdf'):
self.assertFalse(PREPARE(str(self.steam), path))
self.assertEqual(self.original, self.path.read_text())
def test_subdirectory_and_case_sensitive_linux_files_are_resolved(self):
support = self.installer_dir / 'Support'
support.mkdir()
installer = support / 'vcredist_x86.exe'
installer.touch()
self.path.write_text(self.original.replace('vcredist_x86_sp1_atl_4053.exe',
r'support\\\\VCREDIST_X86.EXE'))
def resolver(source, cache):
self.assertTrue(Path(source).samefile(installer))
return self.msi
with patch.dict(PREPARE.__globals__, redist_msi=resolver):
self.assertTrue(self.prepare())
def test_installer_traversal_is_untouched(self):
self.path.write_text(self.original.replace('vcredist_x86_sp1_atl_4053.exe',
r'..\\\\vcredist_x86.exe'))
before = self.path.read_bytes()
self.assertFalse(self.prepare())
self.assertEqual(before, self.path.read_bytes())
def test_archive_extraction_is_limited_to_recognized_flat_members(self):
installer = Path(self.tmp.name) / 'vcredist_x86.exe'
installer.write_bytes(b'fixture')
cache = Path(self.tmp.name) / 'cache'
def archive_run(argv, **kwargs):
if argv[1] == '-tf':
return type('Result', (), {'stdout': 'vcredist.msi\nvcredis1.cab\n../../outside\n'})()
self.assertEqual(argv[-3:], ['--', 'vcredist.msi', 'vcredis1.cab'])
destination = Path(argv[argv.index('-C') + 1])
(destination / 'vcredist.msi').write_bytes(b'\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1')
(destination / 'vcredis1.cab').write_bytes(b'MSCF')
with patch.object(COMPAT['subprocess'], 'run', side_effect=archive_run):
result = COMPAT['redist_msi'](str(installer), str(cache))
self.assertTrue(Path(result).is_file())
self.assertFalse((Path(self.tmp.name) / 'outside').exists())
def physx_fixture(self, command=None):
payload = b'verified Wise PhysX bundle fixture'
directory = self.installer_dir / 'PhysX'
directory.mkdir(exist_ok=True)
installer = directory / 'PhysX_SystemSoftware.exe'
installer.write_bytes(payload)
script = '''"evaluatorscript" { "0" {
"install_path" "%s" "compat_installscript" { "run process" {
"PhysX Version" {
"process 1" "%%INSTALLDIR%%\\\\physx\\\\PHYSX_SYSTEMSOFTWARE.EXE"
%s
"HasRunKey" "HKEY_LOCAL_MACHINE\\\\SOFTWARE\\\\AGEIA Technologies"
"MinimumHasRunValue" "81017" "NoCleanUp" "1"
}
"Other step" { "process 1" "%%INSTALLDIR%%\\\\other.exe" }
} }
} }''' % (self.installer_dir, '' if command is None else
'"command 1" "%s"' % command)
self.path.write_text(script)
return installer, hashlib.sha256(payload).hexdigest()
def test_verified_physx_runs_quietly_and_preserves_steam_completion_checks(self):
for command in (None, ''):
installer, digest = self.physx_fixture(command)
with patch.dict(PREPARE.__globals__, QUIET_PHYSX_SHA256=digest):
self.assertTrue(self.prepare())
tokens = COMPAT['tokenize'](self.path.read_text())
decoded = [COMPAT['vdf_value'](t) for t in tokens if t.startswith('"')]
self.assertIn('/qn', decoded)
self.assertIn(r'%INSTALLDIR%\physx\PHYSX_SYSTEMSOFTWARE.EXE', decoded)
self.assertIn('81017', decoded)
self.assertIn(r'HKEY_LOCAL_MACHINE\SOFTWARE\AGEIA Technologies', decoded)
self.assertIn('Other step', decoded)
before = self.path.read_bytes()
self.assertFalse(self.prepare())
self.assertEqual(before, self.path.read_bytes())
def test_physx_other_versions_and_existing_commands_are_untouched(self):
installer, digest = self.physx_fixture()
before = self.path.read_bytes()
self.assertFalse(self.prepare()) # Fixture is not the real bundle's hash.
self.assertEqual(before, self.path.read_bytes())
for command in ('/uninstall', '/custom', '/s', '/qn'):
self.physx_fixture(command)
before = self.path.read_bytes()
with patch.dict(PREPARE.__globals__, QUIET_PHYSX_SHA256=digest):
self.assertFalse(self.prepare())
self.assertEqual(before, self.path.read_bytes())
def test_physx_missing_or_traversing_installer_keeps_original(self):
installer, digest = self.physx_fixture()
installer.unlink()
before = self.path.read_bytes()
self.assertFalse(self.prepare())
self.assertEqual(before, self.path.read_bytes())
self.physx_fixture()
self.path.write_text(self.path.read_text().replace('physx\\\\', '..\\\\'))
before = self.path.read_bytes()
with patch.dict(PREPARE.__globals__, QUIET_PHYSX_SHA256=digest):
self.assertFalse(self.prepare())
self.assertEqual(before, self.path.read_bytes())
if __name__ == '__main__':
unittest.main()