#!/usr/bin/env python3
# SPDX-FileCopyrightText: 2026 Tomasz Kramkowski <tomasz@kramkow.ski>
# 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/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:]))
