diff options
Diffstat (limited to 'src/steamvr_patches/__init__.py')
| -rw-r--r-- | src/steamvr_patches/__init__.py | 125 |
1 files changed, 125 insertions, 0 deletions
diff --git a/src/steamvr_patches/__init__.py b/src/steamvr_patches/__init__.py new file mode 100644 index 0000000..be3b696 --- /dev/null +++ b/src/steamvr_patches/__init__.py @@ -0,0 +1,125 @@ +# SPDX-FileCopyrightText: 2026 Tomasz Kramkowski <tomasz@kramkow.ski> +# SPDX-License-Identifier: GPL-3.0-or-later + +import argparse +import hashlib +import sys +import tempfile +import tomllib +from base64 import b85encode +from importlib.resources import files +from io import BytesIO +from json import dumps +from pathlib import Path +from typing import Annotated, BinaryIO, Self +from zlib import compress + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from .elf import Hook, compile_hooks + +NonnegativeInt = Annotated[int, Field(strict=True, ge=0)] + + +class Compilation(BaseModel): + model_config = ConfigDict(extra="forbid") + + sources: list[Path] = Field(min_length=1) + imports: dict[str, NonnegativeInt] = Field(default_factory=dict) + hooks: list[Hook] = Field(min_length=1) + + +class FilePatch(BaseModel): + model_config = ConfigDict(val_json_bytes="hex", extra="forbid") + + path: Path + sha256: bytes = Field(min_length=32, max_length=32) + edits: list[tuple[NonnegativeInt, bytes]] + compiled: Compilation | None = None + + +class PatchSet(BaseModel): + model_config = ConfigDict(extra="forbid") + + target: str + targets: list[FilePatch] + + @field_validator("targets") + @classmethod + def unique_paths(cls, targets: list[FilePatch]) -> list[FilePatch]: + paths = [target.path for target in targets] + if len(set(paths)) != len(paths): + raise ValueError("duplicate patch target") + return targets + + @classmethod + def parse(cls, config: dict, directory: Path) -> Self: + result = cls.model_validate(config) + for target in result.targets: + if target.compiled is not None: + target.compiled.sources = [ + directory / source for source in target.compiled.sources + ] + return result + + +def compile_patch(config: FilePatch, source: BinaryIO, output: Path) -> dict: + source.seek(0) + data = source.read() + if hashlib.sha256(data).digest() != config.sha256: + raise ValueError(f"input SHA-256 mismatch: {config.path}") + edits = list(config.edits) + if config.compiled is not None: + compiled = config.compiled + edits += compile_hooks( + source, compiled.sources, compiled.imports, compiled.hooks, output + ) + end = 0 + for offset, replacement in sorted(edits): + if offset < end: + raise ValueError("overlapping edits") + end = offset + len(replacement) + patched = BytesIO(data) + for offset, replacement in edits: + patched.seek(offset) + patched.write(replacement) + return { + "original_hash": config.sha256, + "patched_hash": hashlib.sha256(patched.getbuffer()).digest(), + "edits": edits, + } + + +def render(patches: dict[str, dict]) -> str: + template = files("steamvr_patches").joinpath("template.py").read_text() + placeholder = "PATCH_DATA = {}" + if template.count(placeholder) != 1: + raise ValueError("missing or duplicate runtime patch-table placeholder") + data = dumps(patches, default=bytes.hex, separators=(",", ":")).encode() + encoded = b85encode(compress(data, level=9)) + return template.replace(placeholder, f"PATCH_DATA = decode_patch_data({encoded!r})") + + +def generate(patch_data: Path, input_root: Path) -> str: + with (patch_data / "patches.toml").open("rb") as source: + definition = PatchSet.parse(tomllib.load(source), patch_data) + patches = {} + with tempfile.TemporaryDirectory(prefix="generate-patcher-") as temporary: + for index, config in enumerate(definition.targets): + with (input_root / config.path).open("rb") as source: + patches[config.path.as_posix()] = compile_patch( + config, source, Path(temporary) / str(index) + ) + return render(patches) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument( + "patch_data", type=Path, help="directory containing patches.toml etc" + ) + parser.add_argument( + "input_root", type=Path, help="directory containing the unpatched target files" + ) + args = parser.parse_args() + sys.stdout.write(generate(args.patch_data, args.input_root)) |
