diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 33f9fcc..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -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 }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c18dd8d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/README.md b/README.md index d3f6f37..a00aee7 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,8 @@ Disabling Easy Anti-Cheat in trialsrising.exe so that the game works on Linux + ## 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: `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` +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). + --- diff --git a/patch.py b/patch.py index 674e24f..972d4f9 100644 --- a/patch.py +++ b/patch.py @@ -1,5 +1,7 @@ import sys import getpass +import os +import tempfile # Check if user specified a path 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, # 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' +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 @@ -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 # 24 78 fe # 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: # mov al, 1 # ret -# (then the rest of these bytes don't matter because they will never be reached) # 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: - # read the .exe file data = file.read() except FileNotFoundError: 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") sys.exit(1) -# Perform the replacement, limited to 1 occurrence -patched_data = data.replace(original_bytes, new_and_improved_bytes, 1) - -# Ensure we actually changed *something* -if patched_data != data: - # Write the modified data back to the file - 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: +# 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.") - else: - 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("python patch.py /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(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}.") diff --git a/patcher.spec b/patcher.spec deleted file mode 100644 index 63ba4d2..0000000 --- a/patcher.spec +++ /dev/null @@ -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, -) \ No newline at end of file