aboutsummaryrefslogtreecommitdiffstats
path: root/src/steamvr_patches/template.py
diff options
context:
space:
mode:
authorTomasz Kramkowski <tomasz@kramkow.ski>2026-11-03 20:01:42 +0000
committerTomasz Kramkowski <tomasz@kramkow.ski>2026-11-03 20:01:42 +0000
commit098bd37f355a77abbb5751fc55a91f7ebe74cc6b (patch)
treed3bc93cb427276a4936d79903b62e156d5c538e7 /src/steamvr_patches/template.py
parent64c349eb1dc0fde65fd4e6ddf525faa0a4c64a91 (diff)
downloadsteamvr-patches-098bd37f355a77abbb5751fc55a91f7ebe74cc6b.tar.gz
steamvr-patches-098bd37f355a77abbb5751fc55a91f7ebe74cc6b.tar.xz
steamvr-patches-098bd37f355a77abbb5751fc55a91f7ebe74cc6b.zip
use patch generator to build patchersHEADmaster
Diffstat (limited to 'src/steamvr_patches/template.py')
-rw-r--r--src/steamvr_patches/template.py169
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:]))