aboutsummaryrefslogtreecommitdiffstats
path: root/frame-steamos/patch-frame
blob: 7b52cb2316042758a271e0b55e90b0fcb16b9032 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
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()