Files
Trials-Rising-Steam-Deck-Patch/patch.py
T
Kyle Mohr f6762b23f6 Thanks to @rogerclotet for the suggestion #5 - this adds support for
other locations where Trials: Rising could have been installed by Steam
2026-09-26 11:41:27 -05:00

145 lines
7.0 KiB
Python

import sys
import getpass
import os
import re
import tempfile
from pathlib import Path
GAME_RELATIVE = Path('steamapps/common/Trials Rising/datapack/trialsrising.exe')
def steam_library_candidates(user):
"""Yield candidate paths to trialsrising.exe across every Steam library.
Rather than guessing at device names or mount points (for example, assuming the microSD card
is always at /run/media/mmcblk0p1), read Steam's own libraryfolders.vdf, which lists every
installed library no matter where it lives (internal drive, SD card, external drive, ...).
"""
home = Path('/home') / user
steam_roots = [
home / '.local/share/Steam', # standard Linux / SteamOS
home / '.steam/steam', # older / symlinked installs
home / '.var/app/com.valvesoftware.Steam/.local/share/Steam', # Flatpak Steam
home / 'snap/steam/common/.local/share/Steam', # Snap Steam
]
for root in steam_roots:
# The library that lives directly under the Steam root (usually the default one).
yield root / GAME_RELATIVE
try:
text = (root / 'steamapps' / 'libraryfolders.vdf').read_text(encoding='utf-8', errors='replace')
except OSError:
continue
# Each library root is stored as: "path" "/run/media/mmcblk0p1"
for library in re.findall(r'"path"\s+"([^"]+)"', text):
yield Path(library) / GAME_RELATIVE
# Check if user specified a path
if len(sys.argv) > 1:
path = sys.argv[1]
else:
user = getpass.getuser()
path = next(
(str(candidate) for candidate in steam_library_candidates(user) if candidate.is_file()),
# Last-resort default, so the "file not found" hint still names a real location.
'/home/' + user + '/.local/share/Steam/' + str(GAME_RELATIVE),
)
# Some explanation: these bytes first serve as hex representations of the program instructions we want to change
# But if that was all they needed to be, they could each be 3 hex characters long:
# original_bytes = b'\x48\x89\x54'
# new_and_improved_bytes = b'\xB0\x01\xC3'
# However the issue is that this sequence of original_bytes shows up many many times in the executable,
# so we would end up patching many places that don't need to be patched,
# causing unintended functionality (likely) or else just causing the program to crash (extremely likely)
# instead, we specify *just* enough bytes such that the sequence only occurs in one place:
original_bytes = (
b'\x48\x89\x54\x24\x10\x55\x56\x57\x48\x81\xEC\xF0\x00\x00\x00\x48\xC7\x44\x24\x78'
)
# translated (from Ghidra (used to disassemble this program)): 15a0b00dc(*)
# 14178b6c0 48 89 54 MOV qword ptr [RSP + 0x10],RDX
# 24 10
# 14178b6c5 55 PUSH RBP
# 14178b6c6 56 PUSH RSI
# 14178b6c7 57 PUSH RDI
# 14178b6c8 48 81 ec SUB RSP,0xf0
# f0 00 00 00
# 14178b6cf 48 c7 44 MOV qword ptr [RSP + 0x78],-0x2
# 24 78 fe
# ff ff ff
new_and_improved_bytes = (
b'\xB0\x01\xC3\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90\x90'
)
# Translated:
# mov al, 1
# ret
# or in pseudocode, `return True;`
# (The rest of the bytes won't be reached, but they are there to ensure that the length of the new_and_improved_bytes is the same as the original_bytes, so that we don't accidentally shift any other bytes in the executable.)
# Older releases of this patcher wrote the original tail bytes back after `B0 01 C3` instead of
# NOP padding. Detect that form too, so people who already patched with an older version don't
# get a misleading "couldn't detect the patch" error.
old_patched_bytes = b'\xB0\x01\xC3\x24\x10\x55\x56\x57\x48\x81\xEC\xF0\x00\x00\x00\x48\xC7\x44\x24\x78'
# Sanity check: the replacement must be exactly the same length as the original so we never shift
# any other bytes in the executable. This is a hard requirement, so use sys.exit rather than assert
# (assert statements are stripped when Python runs with -O, which would silently remove this guard).
if len(original_bytes) != len(new_and_improved_bytes):
sys.exit("Internal error: original_bytes and new_and_improved_bytes differ in length. Refusing to patch.")
try:
with open(path, 'rb') as file:
data = file.read()
except FileNotFoundError:
print(f"Error: The file '{path}' was not found.")
print("Hint: specify the path to the .exe as a command line argument, like this:")
print(f"python {sys.argv[0]} /path/to/trialsrising.exe")
sys.exit(1)
# The signature is chosen to be unique to this one function. Enforce that at runtime: patching the
# wrong occurrence would corrupt the executable, so fail loudly instead of silently patching one.
matches = data.count(original_bytes)
if matches == 0:
# Nothing to patch. Either it's already patched, or we don't recognize this build.
if data.count(new_and_improved_bytes) or data.count(old_patched_bytes):
print("The patch had already been applied to your .exe, no further changes were needed.")
sys.exit(0)
print("The original bytes were not found in the binary, and we couldn't detect the patch either. \n\n \
Please try running the patch again with the full path to your .exe, like this:")
print(f"{sys.argv[0]} /path/to/trialsrising.exe")
print("")
print("If that still doesn't work, open an issue to let me know: https://github.com/kylefmohr/Trials-Rising-Steam-Deck-Patch/issues")
sys.exit(1)
elif matches > 1:
sys.exit(
f"Error: expected exactly 1 match for the original bytes, but found {matches}. "
"Refusing to patch to avoid corrupting the executable. Please open an issue."
)
# Keep a one-time backup of the original so the patch can be reverted (for example, after a game update).
backup_path = path + '.bak'
if not os.path.exists(backup_path):
with open(backup_path, 'wb') as backup:
backup.write(data)
# Write atomically: `open(path, 'wb')` truncates the file immediately, so an interrupted write would
# leave an unusable large executable. Write to a temp file in the same directory, flush it to disk,
# then atomically replace the original.
patched_data = data.replace(original_bytes, new_and_improved_bytes, 1)
temp_path = None
try:
with tempfile.NamedTemporaryFile('wb', dir=os.path.dirname(os.path.abspath(path)), delete=False) as temp:
temp_path = temp.name
temp.write(patched_data)
temp.flush()
os.fsync(temp.fileno())
# NamedTemporaryFile creates the file as 0600, so copy the original file's permissions over.
os.chmod(temp_path, os.stat(path).st_mode)
os.replace(temp_path, path)
finally:
if temp_path is not None and os.path.exists(temp_path):
os.remove(temp_path)
print(f"Success: Byte sequence found and replaced in {path}.")
print(f"A backup of the original file was saved to {backup_path}.")