changes for clarity - no major changes in function.
remove PyInstaller stuff use NOPs to fill in the end of `new_and_improved_bytes` prevent corrupting the exe if the script is interrupted I verified this works on my Steam copy of Trials: Rising on Linux
This commit is contained in:
1 parent
75a2077b54
commit
3e615cd1a0
5 files changed
+65
-103
No files matched your search
@@ -1,48 +0,0 @@
|
|||||||
name: Package Application with Pyinstaller and Create Release
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- 'v*'
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
release_version:
|
|
||||||
description: 'Release version (e.g., v1.0.0)'
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-and-release:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Python
|
|
||||||
uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: '3.11'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
python -m pip install --upgrade pip
|
|
||||||
pip install pyinstaller
|
|
||||||
|
|
||||||
- name: Build with PyInstaller
|
|
||||||
run: pyinstaller patcher.spec
|
|
||||||
|
|
||||||
- name: Prepare artifact for release
|
|
||||||
run: mv dist/patch dist/trials_patcher-linux
|
|
||||||
|
|
||||||
- name: Create and Publish Release
|
|
||||||
uses: ncipollo/release-action@v1
|
|
||||||
with:
|
|
||||||
|
|
||||||
artifacts: "dist/trials_patcher-linux"
|
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
tag: ${{ github.event.inputs.release_version || github.ref }}
|
|
||||||
releaseName: Release ${{ github.event.inputs.release_version || github.ref }}
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
__pycache__/
|
||||||
@@ -3,6 +3,8 @@ Disabling Easy Anti-Cheat in trialsrising.exe so that the game works on Linux +
|
|||||||
|
|
||||||
|
|
||||||
## Usage:
|
## Usage:
|
||||||
|
This is a Python script, it requires Python 3, which is already installed on SteamOS and virtually every Linux distribution.
|
||||||
|
|
||||||
Run this script on the Linux device that Trials: Rising is installed on with:
|
Run this script on the Linux device that Trials: Rising is installed on with:
|
||||||
|
|
||||||
`python3 patch.py`
|
`python3 patch.py`
|
||||||
@@ -11,6 +13,8 @@ If the patcher says it couldn't find the file, you can specify the path to `tria
|
|||||||
|
|
||||||
`python3 patch.py /path/to/trialsrising.exe`
|
`python3 patch.py /path/to/trialsrising.exe`
|
||||||
|
|
||||||
|
Before patching, the script saves a backup of your original executable to `trialsrising.exe.bak` so you can revert the change (for example, after a game update).
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import sys
|
import sys
|
||||||
import getpass
|
import getpass
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
# Check if user specified a path
|
# Check if user specified a path
|
||||||
if len(sys.argv) > 1:
|
if len(sys.argv) > 1:
|
||||||
@@ -15,7 +17,9 @@ else:
|
|||||||
# so we would end up patching many places that don't need to be patched,
|
# 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)
|
# 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:
|
# 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'
|
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(*)
|
# translated (from Ghidra (used to disassemble this program)): 15a0b00dc(*)
|
||||||
# 14178b6c0 48 89 54 MOV qword ptr [RSP + 0x10],RDX
|
# 14178b6c0 48 89 54 MOV qword ptr [RSP + 0x10],RDX
|
||||||
# 24 10
|
# 24 10
|
||||||
@@ -27,17 +31,28 @@ original_bytes = b'\x48\x89\x54\x24\x10\x55\x56\x57\x48\x81\xEC\xF0\x00\x00\x00\
|
|||||||
# 14178b6cf 48 c7 44 MOV qword ptr [RSP + 0x78],-0x2
|
# 14178b6cf 48 c7 44 MOV qword ptr [RSP + 0x78],-0x2
|
||||||
# 24 78 fe
|
# 24 78 fe
|
||||||
# ff ff ff
|
# ff ff ff
|
||||||
new_and_improved_bytes = b'\xB0\x01\xC3\x24\x10\x55\x56\x57\x48\x81\xEC\xF0\x00\x00\x00\x48\xC7\x44\x24\x78'
|
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:
|
# Translated:
|
||||||
# mov al, 1
|
# mov al, 1
|
||||||
# ret
|
# ret
|
||||||
# (then the rest of these bytes don't matter because they will never be reached)
|
|
||||||
# or in pseudocode, `return True;`
|
# 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:
|
try:
|
||||||
with open(path, 'rb') as file:
|
with open(path, 'rb') as file:
|
||||||
# read the .exe file
|
|
||||||
data = file.read()
|
data = file.read()
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
print(f"Error: The file '{path}' was not found.")
|
print(f"Error: The file '{path}' was not found.")
|
||||||
@@ -45,21 +60,49 @@ except FileNotFoundError:
|
|||||||
print(f"python {sys.argv[0]} /path/to/trialsrising.exe")
|
print(f"python {sys.argv[0]} /path/to/trialsrising.exe")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# Perform the replacement, limited to 1 occurrence
|
# The signature is chosen to be unique to this one function. Enforce that at runtime: patching the
|
||||||
patched_data = data.replace(original_bytes, new_and_improved_bytes, 1)
|
# wrong occurrence would corrupt the executable, so fail loudly instead of silently patching one.
|
||||||
|
matches = data.count(original_bytes)
|
||||||
# Ensure we actually changed *something*
|
if matches == 0:
|
||||||
if patched_data != data:
|
# Nothing to patch. Either it's already patched, or we don't recognize this build.
|
||||||
# Write the modified data back to the file
|
if data.count(new_and_improved_bytes) or data.count(old_patched_bytes):
|
||||||
with open(path, 'wb') as file:
|
|
||||||
file.write(patched_data)
|
|
||||||
print(f"Success: Byte sequence found and replaced in {path}.")
|
|
||||||
else:
|
|
||||||
if data.find(new_and_improved_bytes) >= 1:
|
|
||||||
print("The patch had already been applied to your .exe, no further changes were needed.")
|
print("The patch had already been applied to your .exe, no further changes were needed.")
|
||||||
else:
|
sys.exit(0)
|
||||||
print("The original bytes were not found in the binary, and we couldn't detect the patch either. \n\n \
|
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:")
|
Please try running the patch again with the full path to your .exe, like this:")
|
||||||
print("python patch.py /path/to/trialsrising.exe")
|
print(f"{sys.argv[0]} /path/to/trialsrising.exe")
|
||||||
print("")
|
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")
|
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}.")
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
# -*- mode: python ; coding: utf-8 -*-
|
|
||||||
|
|
||||||
|
|
||||||
a = Analysis(
|
|
||||||
['patch.py'],
|
|
||||||
pathex=[],
|
|
||||||
binaries=[],
|
|
||||||
datas=[],
|
|
||||||
hiddenimports=[],
|
|
||||||
hookspath=[],
|
|
||||||
hooksconfig={},
|
|
||||||
runtime_hooks=[],
|
|
||||||
excludes=[],
|
|
||||||
noarchive=False,
|
|
||||||
optimize=0,
|
|
||||||
)
|
|
||||||
pyz = PYZ(a.pure)
|
|
||||||
|
|
||||||
exe = EXE(
|
|
||||||
pyz,
|
|
||||||
a.scripts,
|
|
||||||
a.binaries,
|
|
||||||
a.datas,
|
|
||||||
[],
|
|
||||||
name='patch',
|
|
||||||
debug=False,
|
|
||||||
bootloader_ignore_signals=False,
|
|
||||||
strip=False,
|
|
||||||
upx=True,
|
|
||||||
upx_exclude=[],
|
|
||||||
runtime_tmpdir=None,
|
|
||||||
console=True,
|
|
||||||
disable_windowed_traceback=False,
|
|
||||||
argv_emulation=False,
|
|
||||||
target_arch=None,
|
|
||||||
codesign_identity=None,
|
|
||||||
entitlements_file=None,
|
|
||||||
)
|
|
||||||
Reference in new issue
Block a user