diff options
| author | Tomasz Kramkowski <tomasz@kramkow.ski> | 2026-11-03 20:01:42 +0000 |
|---|---|---|
| committer | Tomasz Kramkowski <tomasz@kramkow.ski> | 2026-11-03 20:01:42 +0000 |
| commit | 098bd37f355a77abbb5751fc55a91f7ebe74cc6b (patch) | |
| tree | d3bc93cb427276a4936d79903b62e156d5c538e7 /src/steamvr_patches/template.py | |
| parent | 64c349eb1dc0fde65fd4e6ddf525faa0a4c64a91 (diff) | |
| download | steamvr-patches-098bd37f355a77abbb5751fc55a91f7ebe74cc6b.tar.gz steamvr-patches-098bd37f355a77abbb5751fc55a91f7ebe74cc6b.tar.xz steamvr-patches-098bd37f355a77abbb5751fc55a91f7ebe74cc6b.zip | |
Diffstat (limited to 'src/steamvr_patches/template.py')
| -rw-r--r-- | src/steamvr_patches/template.py | 169 |
1 files changed, 169 insertions, 0 deletions
diff --git a/src/steamvr_patches/template.py b/src/steamvr_patches/template.py new file mode 100644 index 0000000..4f88b10 --- /dev/null +++ b/src/steamvr_patches/template.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 Tomasz Kramkowski <tomasz@kramkow.ski> +# SPDX-License-Identifier: CC0-1.0 + +"""Patching wrapper script. + +This file is generated. + +See https://kramkow.ski/project/steamvr-patches.html and +https://the-tk.com/cgit/steamvr-patches/about/ for more information. + +Use at your own risk! + + +Installation (where <thing> is the thing this patcher targets, e.g. steamvr): + +1. Put this `patch-<thing>` script somewhere, e.g. `~/.local/libexec/patch-<thing>` +2. Mark it executable +3. Change the launch options of the specified <thing> to: + `/absolute/path/to/patch-<thing> %command%`. + If you already have something in there, I'm sure you can figure it out. +4. Launch <thing> normally. You should see a desktop notification with some + diagnostics which specify if the script actually did/tried to do something. +""" + +import hashlib +import os +import stat +import subprocess +import sys +import tempfile +from base64 import b85decode +from dataclasses import dataclass +from io import BytesIO +from json import loads as json_loads +from pathlib import Path +from zlib import decompress + +NAME = Path(__file__).name +MARKER = b"Valve, please fix." + + +@dataclass(frozen=True) +class Edit: + offset: int + replacement: bytes + + +@dataclass(frozen=True) +class Patch: + original_hash: bytes + patched_hash: bytes + edits: tuple[Edit, ...] + + @classmethod + def from_dict(cls, data: dict) -> "Patch": + return cls( + bytes.fromhex(data["original_hash"]), + bytes.fromhex(data["patched_hash"]), + tuple( + Edit(offset, bytes.fromhex(replacement)) + for offset, replacement in data["edits"] + ), + ) + + def apply_to(self, data: bytes) -> bytes: + payload = data.removesuffix(MARKER) + digest = sha256(payload) + if digest == self.patched_hash: + return payload + MARKER + if digest != self.original_hash: + if payload != data: + raise LookupError( + "already patched with another set of patches: " + "verify file integrity and restart" + ) + raise LookupError("unrecognized file hash: this wrapper may need updating") + result = BytesIO(payload) + for edit in self.edits: + result.seek(edit.offset) + result.write(edit.replacement) + patched = result.getvalue() + if sha256(patched) != self.patched_hash: + raise ValueError("patched file checksum mismatch") + return patched + MARKER + + +def decode_patch_data(data: bytes) -> dict: + return json_loads(decompress(b85decode(data))) + + +# Just to keep the file small, not for obfuscation. +PATCH_DATA = {} +PATCHES: dict[str, Patch] = { + path: Patch.from_dict(data) for path, data in PATCH_DATA.items() +} + + +def sha256(data): + return hashlib.sha256(data).digest() + + +def notify(message): + print(f"{NAME}: {message}", file=sys.stderr) + try: + subprocess.run( + ["notify-send", NAME, message], + check=False, + timeout=3, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + except (OSError, subprocess.TimeoutExpired): + pass + + +def replace(path, data): + mode = stat.S_IMODE(path.stat().st_mode) + with tempfile.NamedTemporaryFile( + dir=path.parent, prefix=path.name + ".", delete=False + ) as f: + temporary = Path(f.name) + try: + f.write(data) + f.flush() + os.fchmod(f.fileno(), mode) + os.fsync(f.fileno()) + os.replace(temporary, path) + finally: + temporary.unlink(missing_ok=True) + + +def main(command): + diagnostics = [] + if not command: + print(f"Usage: {NAME} command [args...]", file=sys.stderr) + return 2 + try: + root = Path.cwd() + pending = [] + for relative, patch in PATCHES.items(): + path = (root / relative).resolve() + try: + original = path.read_bytes() + except FileNotFoundError as e: + diagnostics.append(f"Skipped `{relative}`: {e}") + continue + try: + updated = patch.apply_to(original) + except ValueError as e: + raise ValueError(f"Patching `{relative}` failed: {e}") from e + except LookupError as e: + diagnostics.append(f"Skipped `{relative}`: {e}") + continue + if updated != original: + pending.append((path, updated)) + for path, updated in pending: + replace(path, updated) + diagnostics.append(f"Patched {path}") + if diagnostics: + notify("\n".join(diagnostics)) + except (OSError, ValueError) as e: + notify("\n".join([f"Encountered an error: {e}", *diagnostics])) + return 1 + os.execvp(command[0], command) + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) |
