#!/usr/bin/env python3 # SPDX-FileCopyrightText: 2026 Tomasz Kramkowski # SPDX-License-Identifier: CC0-1.0 """A Half-Life: Alyx launch wrapper that patches the game. For build 26283546. Use at your own risk! """ import hashlib import os import stat import subprocess import sys import tempfile from dataclasses import dataclass from pathlib import Path 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, ...] 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 build: this wrapper may need updating") result = bytearray(payload) for edit in self.edits: end = edit.offset + len(edit.replacement) if edit.offset < 0 or end > len(result): raise ValueError("patch extends outside the file") result[edit.offset : end] = edit.replacement if sha256(result) != self.patched_hash: raise ValueError("patched file checksum mismatch") return bytes(result) + MARKER def patch( original_hash: str, patched_hash: str, edits: tuple[tuple[int, str], ...] ) -> Patch: return Patch( bytes.fromhex(original_hash), bytes.fromhex(patched_hash), tuple( Edit(offset, bytes.fromhex(replacement)) for offset, replacement in edits ), ) PATCHES = { "game/bin/linuxsteamrt64/librendersystemvulkan.so": patch( "dc034f6b5d2116393bfead77f3265c124687b76766906c53a077c196b21ffa18", "7a52bda8f4e6cc35e5ed46f181c3847400807704cab3fea38b40154d0b977f03", ( # The renderer keeps a pool of "staging" buffers that it uses to # upload data to the GPU. When this pool exceeds 128MiB # (configurable via RenderSystem.VulkanStagingPMBSizeLimitMB in # "gameinfo.gi") of allocations, it will clear it down almost # completely. This can take a good 30+ms which makes for a pretty # unacceptable stutter. There's no need to clear all of it at once. # This patch stops clearing when one staging buffer has been freed. (0x22545D, "eb7b90"), # When trying to re-use a staging buffer, the renderer only looks # at two candidate buffers, and allocates (potentially triggering # the de-allocation above) otherwise. Searching all the buffers # seems fast enough and avoids constant allocation and # de-allocation which seems slower. These patches implement that # search. (0x225B99, "0c496bc4504c8bb403980600004d89f290"), (0x225BB8, "68"), (0x225BC5, "5b"), ( 0x225C21, "4183fc0675194d8b76104d85f675074c8bb3780800004d39d60f856affffff4531ffebb1", ), ), ), "game/bin/linuxsteamrt64/libscenesystem.so": patch( "6d395e42a86d352eed03a794bb877f3fd21e84d4be494daa309ad986208e5c48", "7e47f2c0ba97e60cbedcb154df4017feaf04ad1d2528b942f46da15b7303bbc6", ( # Only advance the instance count after a successful allocation. (0x163C41, "0f86ad0000006690"), (0x163CF4, "89b30c5f0000e9a1ffffff"), # Skip this batch of instances if allocation fails. (0x25BA30, "e929b8ffff90"), (0x25725E, "89853cb7ffff4883bd58b7ffff000f844d490000e9bf470000"), ), ), "game/bin/linuxsteamrt64/libnetworksystem.so": patch( "adab8ab904bb6e751ab98943a707e9df0e3f5cd2a084ff223bb563e8ef91f08d", "cc4d9e260f9ead33b764c1e56f4f0ed03a87ef1a98b54149008f1b14d5b591e9", ( # Use the 8-byte snappy fast path for offsets below 16. (0x1FAEB5, "0f"), (0x1FAEBA, "86"), ), ), } 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:]))