Created
August 20, 2026 14:44
-
-
Save dlukt/764f5568847c56a9f13047ec12de2c12 to your computer and use it in GitHub Desktop.
GRAV (Steam app 332500) expiry kill-switch patcher — fixes the 'App Expired' launch error by pinning the engine's calendar year to 2024. Pure Python 3, verifies the build, backs up, verifies result, supports --status/--undo.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """GRAV (Steam app 332500) — expiry kill-switch patcher. | |
| GRAV's final build (buildid 1763174, Jan 2016) contains a hard-coded | |
| expiry date check: after ~9 years it shows "App Expired - This app has | |
| expired. Please get the latest update from steam." and quits, even for | |
| valid owners. The check reads the calendar year through four engine | |
| wrapper functions that all extract it with the same 6-byte instruction: | |
| 44 0f b7 5c 24 20 movzx r11d, WORD PTR [rsp+0x20] ; wYear | |
| This script replaces each occurrence, same length, with: | |
| 41 bb e8 07 00 00 mov r11d, 2024 | |
| so the engine reports year 2024 forever and the kill-switch never | |
| trips. All other timekeeping (durations, timers, file times) is real. | |
| Usage: | |
| patch_grav.py ensure patched (backs up first run) | |
| patch_grav.py --status just report the current state | |
| patch_grav.py --undo restore the original from the backup | |
| patch_grav.py <path> operate on a different copy of the exe | |
| """ | |
| import os | |
| import re | |
| import shutil | |
| import stat | |
| import sys | |
| from pathlib import Path | |
| if sys.platform.startswith("win"): | |
| DEFAULT_EXE = Path(os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")) / ( | |
| "Steam/steamapps/common/GRAV/Binaries/Win64/CAGGame-Win64-Shipping.exe" | |
| ) | |
| else: | |
| DEFAULT_EXE = Path.home() / ( | |
| ".local/share/Steam/steamapps/common/GRAV/Binaries/Win64/" | |
| "CAGGame-Win64-Shipping.exe" | |
| ) | |
| PATCH_FROM = bytes.fromhex("440fb75c2420") # movzx r11d, WORD [rsp+0x20] | |
| PATCH_TO = bytes.fromhex("41bbe8070000") # mov r11d, 2024 | |
| EXPECTED_SITES = 4 | |
| BACKUP_SUFFIX = ".orig.bak" | |
| def find(data: bytes, pat: bytes): | |
| return [m.start() for m in re.finditer(re.escape(pat), data)] | |
| def main() -> int: | |
| args = [a for a in sys.argv[1:]] | |
| undo = "--undo" in args | |
| status_only = "--status" in args | |
| paths = [Path(a) for a in args if not a.startswith("--")] | |
| exe = paths[0] if paths else DEFAULT_EXE | |
| backup = exe.with_name(exe.name + BACKUP_SUFFIX) | |
| if not exe.is_file(): | |
| print(f"ERROR: game executable not found: {exe}") | |
| print("Pass the path explicitly if GRAV is installed elsewhere.") | |
| return 1 | |
| data = exe.read_bytes() | |
| unpatched = find(data, PATCH_FROM) | |
| patched = find(data, PATCH_TO) | |
| already = len(patched) >= EXPECTED_SITES and not unpatched | |
| print(f"Executable : {exe}") | |
| print(f"Size : {len(data):,} bytes") | |
| print(f"Unpatched sites: {len(unpatched)} patched sites: {len(patched)}") | |
| if undo: | |
| if not backup.is_file(): | |
| print("ERROR : no backup found, cannot undo.") | |
| return 1 | |
| shutil.copy2(backup, exe) | |
| print("STATUS : original executable restored from backup.") | |
| return 0 | |
| if already: | |
| print("STATUS : already patched (kill-switch disabled). Nothing to do.") | |
| return 0 | |
| if status_only: | |
| print("STATUS : not patched.") | |
| return 0 | |
| if len(unpatched) != EXPECTED_SITES: | |
| print( | |
| f"ERROR : expected exactly {EXPECTED_SITES} patch sites, " | |
| f"found {len(unpatched)}. This is probably a different build — " | |
| "aborting without changes." | |
| ) | |
| return 1 | |
| if not backup.is_file(): | |
| shutil.copy2(exe, backup) | |
| print(f"Backup : saved {backup.name}") | |
| else: | |
| print(f"Backup : {backup.name} already exists, left untouched.") | |
| buf = bytearray(data) | |
| for off in unpatched: | |
| buf[off : off + len(PATCH_TO)] = PATCH_TO | |
| tmp = exe.with_name(exe.name + ".patching.tmp") | |
| tmp.write_bytes(buf) | |
| os.chmod(tmp, stat.S_IMODE(exe.stat().st_mode)) | |
| tmp.replace(exe) | |
| verify = exe.read_bytes() | |
| if len(find(verify, PATCH_TO)) != len(unpatched) or find(verify, PATCH_FROM): | |
| print("ERROR : verification failed! Restore from the backup.") | |
| return 1 | |
| print(f"STATUS : patched {len(unpatched)} sites. Kill-switch disabled.") | |
| print("Remember : Steam 'Verify integrity of game files' will undo this;") | |
| print(" re-run this script afterwards to re-apply.") | |
| return 0 | |
| if __name__ == "__main__": | |
| sys.exit(main()) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment