#!/usr/bin/env python3
# SPDX-FileCopyrightText: 2026 Tomasz Kramkowski <tomasz@kramkow.ski>
# SPDX-License-Identifier: CC0-1.0

"""A steamvr launch command wrapper that patches various parts of steamvr
to fix bugs.

For SteamVR Beta 2.18.4, build 25998080.

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 a 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 = {
    "bin/linux64/vrcompositor": patch(
        "ebdffa9a8309d9596201cb385ceded35b71a1f16fb8f123fc0196c245cb8186c",
        "47c721d16d4da568e8d6344d9e7ef79b155afe557998780491f25e554f9761af",
        (
            # Wait for GPU to finish using its resources (see #952)
            (0x3E7FC1, "84c00f84cf560000eb06"),
            (0x3E7FD1, "488dbbe00d0000e9aa560000"),
            (0x3ED67C, "f0c1"),
            (0x3ED680, "e93ca9ffff9090"),
            # Stop reusing GPU work identifier across frames
            # (allows above patch to work reliably)
            (0x40049B, "31c09090909090"),
            (0x400BBE, "31c09090909090"),
            (0x400BF2, "909090909090"),
            # Fix shader descriptor texture array counts (should be 3, not 1)
            # for bindings 9, 17, and 20. Affected shaders:
            # - distort_none_ps
            # - distort_none_ps_layered
            # - distort_panorama_ps
            # - distort_panorama_solid_color_ps
            # - distort_ps_achromatic_nd
            (0x3E7FE1, "498b5500b80300000089424ceb22"),
            (0x3E8011, "89828c0000008982a4000000eb12"),
            (0x3E8031, "4c8bbd18feffffe9e21d0100"),
            (0x3F9E18, "e9c4e1feff9090"),
        ),
    ),
    "bin/linux64/vrserver": patch(
        "fb87eefff0cad90c69c73cfe6affdb3c752f2047c4fa3bb63e478bcec05d9d18",
        "28a91a9c677052d6fb62cfd015fe0e4bbf946a57e0f8195abf221e434ac9cbf2",
        (
            # Remove logger calls in forked processes (can cause a deadlock and
            # make the process linger preventing SteamVR from starting again)
            (0x2BD5F6, "9090909090"),
            (0x2BD7E7, "9090909090"),
            (0x2BD87C, "9090909090"),
            (0x2BD976, "9090909090"),
            (0x2BDA4F, "9090909090"),
        ),
    ),
    "drivers/vrlink/bin/linux64/driver_vrlink.so": patch(
        "19db86058ca8243bf9e9f4cad456ec64f2038709c1570afbda60f82694fd6319",
        "2eb30ceceba4e10d412ea7f5a54430600b25090e92b79bcf65d59cff49cac045",
        (
            # Keep increasing (mod(2^16)) the ACK cutoff instead of clearing it
            # (#965)
            (0xA1F4D, "89836c0d000090909090"),
            # Wait for encoder to finish before reconfiguring it
            # (ulVideoTimeline == ulTimelineSequence) (#965)
            (0x0FDDE2, "83bb4c090000007458e9702c0000"),
            (0x0FDE43, "488b8328090000e9ec210000"),
            (0x100034, "e9a9ddffff9090"),
            # Clean up failed encoded frames so the above fix doesn't wait
            # forever when these trigger a reset
            (0xFF246, "27000000"),
            (0xFF271, "488b9d70ffffff4c01f3e9d6feffff"),
            # Reset tracking and encoding when starting a new streaming session
            (0xD5A31, "57488b7f184885ff740feb04"),
            (0xD5A41, "488b076a015eff50285fe95055feff"),
            (0xDD171, "4c89a30004000031c0894308c3"),
            (0xDD1B7, "e8b5ffffff6690"),
            (0xDD1CB, "6288ff"),
            # Ignore stale tracking IDs and repeated older attempts
            (0xDE1CE, "89d02b47084189c6c1e004"),
            (0xDE1DA, "88"),
            (0xDE6C1, "eb"),
            (0xDE6F8, "88430eebc69090"),
        ),
    ),
}


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:]))
