diff options
Diffstat (limited to 'frame-steamos')
| -rwxr-xr-x | frame-steamos/install | 12 | ||||
| -rwxr-xr-x | frame-steamos/patch-frame | 216 | ||||
| -rw-r--r-- | frame-steamos/patch-frame.service | 16 |
3 files changed, 244 insertions, 0 deletions
diff --git a/frame-steamos/install b/frame-steamos/install new file mode 100755 index 0000000..d36b0df --- /dev/null +++ b/frame-steamos/install @@ -0,0 +1,12 @@ +#!/bin/sh +# SPDX-FileCopyrightText: 2026 Tomasz Kramkowski <tomasz@kramkow.ski> +# SPDX-License-Identifier: CC0-1.0 + +set -e + +install -Dm755 patch-frame /var/opt/patch-frame/patch-frame +install -Dm644 patch-frame.service /etc/systemd/system/patch-frame.service +systemctl daemon-reload +systemctl enable patch-frame.service + +printf '%s\n' 'Installed. Reboot to apply the patches.' >&2 diff --git a/frame-steamos/patch-frame b/frame-steamos/patch-frame new file mode 100755 index 0000000..7b52cb2 --- /dev/null +++ b/frame-steamos/patch-frame @@ -0,0 +1,216 @@ +#!/usr/bin/python3 -I +# SPDX-FileCopyrightText: 2026 Tomasz Kramkowski <tomasz@kramkow.ski> +# SPDX-License-Identifier: CC0-1.0 + +"""A minimally intrusive Frame SteamOS patcher""" + +import argparse +import hashlib +import json +import os +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + +MARKER = "x-valve-please-fix" +RUNTIME = Path(os.environ.get("RUNTIME_DIRECTORY", "/run/patcher")) + + +@dataclass(frozen=True) +class Edit: + offset: int + replacement: bytes + + +@dataclass(frozen=True) +class Patch: + original_hash: bytes + edits: tuple[Edit, ...] + + def apply_to(self, data: bytes) -> bytearray: + result = bytearray(data) + for edit in self.edits: + end = edit.offset + len(edit.replacement) + if edit.offset < 0 or end > len(result): + raise ValueError(f"Patch outside file at {edit.offset:#x}") + result[edit.offset : end] = edit.replacement + return result + + +def patch(original_hash: str, edits: tuple[tuple[int, str], ...]) -> Patch: + return Patch( + bytes.fromhex(original_hash), + tuple( + Edit(offset, bytes.fromhex(replacement)) for offset, replacement in edits + ), + ) + + +PATCHES = { + "/opt/steamvr/tools/vrlink/bin/linuxarm64/vrlink": patch( + original_hash="31addef0c9553d3770b5570487699e96d70271d4073cd6c03b080fdee6ae2153", + edits=( + # Clear the lists of ready and submitted frame IDs after a reset. + # If not done, this can cause empty frames to be submitted to the + # decoder, or skip submitting a received frame that hasn't been + # submitted since the reset. + (0x53EB4, "75d40214"), + ( + 0x109088, + "6072049161720c9102008092020881a81f0001ebc1ffff54616202916082249103048052020881a863040071c1ffff54802bfd17", + ), + # Let the decoder worker continue running when either DQBUF returns + # EAGAIN. This can happen after a reset, and will trigger more + # resets, and can only recover after the decoder stalls for at + # least an absurd 0.8s. + (0x32D50, "db580314"), + (0x1090BC, "090040b93f2d0071e0d29454130040b922a7fc17"), + (0x32D90, "d0580314"), + (0x1090D0, "090040b93f2d007140d89454130040b92da7fc17"), + # Fix a possible bug where a missing frame wouldn't trigger a + # request when a newer frame arrived and the gap straddled the 16 + # bit wrapping boundary. + (0x53F84, "a102024b213c00133f080071ab0100547316931a06000014"), + (0x53FB0, "613e0053"), + # Ignore packets which are for an older frame when re-assembling. + # There are 16 slots and this could otherwise cause an old packet + # to override a newer one. + (0x53F34, "6cd40214"), + (0x1090E4, "c00a40b96000f837a002004b60007837c0324039902bfd17b22bfd17"), + # Avoid two corner cases: + # - After reset, wait for a complete recovery frame instead of + # simply waiting for _a_ frame to avoid trying to start decoding + # from a frame which depends on prior ones. + # - Ignore frames which complete reassembly but which are older + # than the last submitted frame. + (0x541E0, "c8d30214"), + ( + 0x109100, + "092061f92a1941b9ca00f8370b0840b96b010a4b0b017837fd7bbea9322cfd170a3040398a0000340a1040914a754d394aff0f37c0035fd6", + ), + # To compensate for the first of the above two fixes, any frames + # which completed out of order before a recovery frame must be + # revisited after that frame. + (0x53A90, "41b55a54"), + ( + 0x109138, + "800e4092000f008b004c40f9010840b93f00146be150a554242cfd97852afd17", + ), + # If decoder submission fails, request reset without advancing the + # last-submitted frame number as though submission succeeded. + (0x53B08, "94d50214"), + (0x109158, "4050a534001b46f96b2afd17"), + # With FEC, audio gets sent as two blocks (A, B) followed by their + # parity (A^B) block. If the next block arrives (C) while the + # headset has received B but not A and is still waiting for A^B + # then B gets discarded without playing it. This loses two blocks + # worth of audio (10.67ms) rather than just skipping one missed + # block (5.33ms) and flushing the second one (B). This fixes that + # issue by just flushing B in that case. + (0x3ADA8, "ef380314"), + ( + 0x109164, + "2000004b6000f836e2ff9f520000020b1f080071ece19854cbe49854620080522308c21a6384021b43e1983421c7fc17", + ), + # Patch incorrect sequence gap calculation when sequence numbers + # wrap modulo 65535 (recurring theme...) + (0x3AFB0, "79380314"), + (0x109194, "6000f836e1ff9f520000010b0dc7fc17"), + # Don't request retransmission of frames that the headset has + # already moved ahead of (i.e. the last-submitted frame or older). + # Assume that a frame with a size of 4 MiB with missing metadata is + # not actually 4 MiB to avoid requesting way too many potentially + # missing fragments automatically. This can cause too many requests + # which triggers an unnecessary stream reset. Requests for the + # frame's metadata fragment continue which will contain and + # populate the size field allowing the retransmit request code to + # work normally from that point. + (0x52BF8, "6bd90214"), + ( + 0x1091A4, + "832261f9641841b99f04003180000054e400044bc4e3a434e40078378032403960e2a435951240b9bf025071f503951a8d26fd171627fd17", + ), + # Don't trigger a reset if more than 512 missing fragments need to + # be requested as a result of a time based repair. Instead stop + # early once 20 entries are accumulated, reserving an extra 21st + # entry so that a request for a fresh metadata fragment can always + # be sent. This avoids an unnecessary reset and can speed up + # recovery. + (0x52E8C, "d4d80214"), + ( + 0x1091DC, + "130080523f04003161e5a45484364079a0fe0f11007c0a539f00006bc2e4a45402008012e2c30079330080522227fd17", + ), + (0x52EC8, "d1d80214"), + (0x10920C, "8a3240394a01130a4a4d0011852640b92c27fd17"), + (0x52F38, "7f020a6b6c040054"), + (0x52F98, "7f020a6b6c010054"), + (0x52FC0, "7f020a6bedeaff5453050011647a1f53"), + ), + ), +} + + +def start(): + for name, patch in PATCHES.items(): + target = Path(name).resolve(strict=True) + data = target.read_bytes() + checksum = hashlib.sha256(data).digest() + if checksum != patch.original_hash: + print( + f"Skipping {target}: unrecognised checksum {checksum.hex()}", + file=sys.stderr, + ) + continue + patched = patch.apply_to(data) + source = RUNTIME / hashlib.sha256(os.fsencode(target)).hexdigest() + source.parent.mkdir(parents=True, exist_ok=True) + source.write_bytes(patched) + attributes = target.stat() + os.chown(source, attributes.st_uid, attributes.st_gid) + source.chmod(attributes.st_mode) + subprocess.run( + ["mount", "-o", f"bind,ro,{MARKER}", str(source), str(target)], + check=True, + ) + print(f"Patched {target}", file=sys.stderr) + + +def stop(): + result = subprocess.run( + [ + "findmnt", + "--mtab", + "--options", + MARKER, + "--json", + "--output", + "TARGET", + ], + check=True, + capture_output=True, + text=True, + ) + for target in reversed( + [ + Path(row["target"]) + for row in json.loads(result.stdout).get("filesystems", []) + ] + ): + subprocess.run(["umount", str(target)], check=True) + print(f"Unmounted {target}", file=sys.stderr) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("action", choices=("start", "stop")) + args = parser.parse_args() + if args.action == "start": + start() + else: + stop() + + +if __name__ == "__main__": + main() diff --git a/frame-steamos/patch-frame.service b/frame-steamos/patch-frame.service new file mode 100644 index 0000000..78ced74 --- /dev/null +++ b/frame-steamos/patch-frame.service @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: 2026 Tomasz Kramkowski <tomasz@kramkow.ski> +# SPDX-License-Identifier: CC0-1.0 + +[Unit] +Description=Apply Steam Frame patches +Before=display-manager.service user@1000.service + +[Service] +Type=oneshot +RemainAfterExit=yes +RuntimeDirectory=patch-frame +ExecStart=/var/opt/patch-frame/patch-frame start +ExecStopPost=/var/opt/patch-frame/patch-frame stop + +[Install] +WantedBy=multi-user.target |
