aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--README.md14
-rwxr-xr-xframe-steamos/install12
-rwxr-xr-xframe-steamos/patch-frame216
-rw-r--r--frame-steamos/patch-frame.service16
4 files changed, 258 insertions, 0 deletions
diff --git a/README.md b/README.md
index 524486f..eff86b6 100644
--- a/README.md
+++ b/README.md
@@ -18,3 +18,17 @@ information.
4. Launch the game/tool normally. You should see a desktop notification with
some diagnostics which specify if the script actually did/tried to do
something.
+
+## Installing the Frame SteamOS patcher
+
+1. Put the contents of the `frame-steamos` directory onto your headset.
+2. Run `sudo ./install` while inside that directory.
+3. Reboot the headset.
+4. Check the journal: `journalctl -eu patch-frame.service` you should expect to
+ see something like:
+
+```
+systemd[1]: Starting Apply Steam Frame patches...
+patch-frame[1080]: Patched /opt/steamvr/tools/vrlink/bin/linuxarm64/vrlink
+systemd[1]: Finished Apply Steam Frame patches.
+```
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