mirror of
https://github.com/spoopyghosty0/frameport.git
synced 2026-10-16 16:01:26 +02:00
Compare commits
2
Commits
main
...
obb-mount-emul
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81a015e19b | ||
|
|
4b0b47d305 |
No files matched your search
+323
-14
@@ -44,7 +44,7 @@ try:
|
||||
except ImportError: # Windows: pc_revive loads this file for its VDF code only (GitHub #131)
|
||||
fcntl = None
|
||||
|
||||
AGENT_VERSION = 91
|
||||
AGENT_VERSION = 92
|
||||
HOME = os.path.expanduser("~")
|
||||
STEAM = os.path.join(HOME, ".local/share/Steam")
|
||||
ANCHORS = os.path.join(HOME, "Applications/quest-frame")
|
||||
@@ -2933,12 +2933,7 @@ def cmd_prepare(args):
|
||||
incoming = os.path.join(base, "incoming")
|
||||
os.makedirs(os.path.join(incoming, "obb"), exist_ok=True)
|
||||
app = os.path.join(base, "lepton-app")
|
||||
existing = {}
|
||||
obb = os.path.join(app, "obb")
|
||||
for root, _, files in os.walk(obb):
|
||||
for name in files:
|
||||
p = os.path.join(root, name)
|
||||
existing[os.path.relpath(p, obb)] = os.path.getsize(p)
|
||||
existing = obb_manifest(os.path.join(app, "obb")) # an unpacked OBB image counts as the image (unpack_obb)
|
||||
existing.update(tree_manifest(os.path.join(incoming, "obb"))) # uploaded before an interruption
|
||||
apk = os.path.join(app, "game.apk")
|
||||
want_sha = args.get("apk_sha256")
|
||||
@@ -3937,13 +3932,7 @@ def cmd_finalize(args):
|
||||
inc_obb = os.path.join(incoming, "obb")
|
||||
expected = args.get("obb_manifest") # rel path -> size
|
||||
if expected: # check the data as it will be after the move, before replacing anything (a failure keeps the old)
|
||||
have = {}
|
||||
for top in (os.path.join(app, "obb"), inc_obb):
|
||||
for root, _, files in os.walk(top):
|
||||
for name in files:
|
||||
if not name.endswith(".part"):
|
||||
p = os.path.join(root, name)
|
||||
have[os.path.relpath(p, top)] = os.path.getsize(p)
|
||||
have = {**obb_manifest(os.path.join(app, "obb")), **obb_manifest(inc_obb)}
|
||||
bad = [k for k, v in expected.items() if have.get(k) != v]
|
||||
if bad:
|
||||
raise AgentError(f"{len(bad)} data file(s) missing or incomplete, e.g. {bad[0]}")
|
||||
@@ -3972,6 +3961,8 @@ def cmd_finalize(args):
|
||||
continue # an older interrupted upload is not part of this install
|
||||
dst = os.path.join(app, "obb", rel)
|
||||
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
||||
if os.path.isdir(dst) and obb_unpacked_size(dst) is not None:
|
||||
shutil.rmtree(dst) # a new version of an unpacked OBB image (unpacked again: unpack_obb)
|
||||
os.replace(src, dst)
|
||||
moved += 1
|
||||
shutil.rmtree(incoming, ignore_errors=True)
|
||||
@@ -4432,6 +4423,324 @@ def tree_manifest(root):
|
||||
return out
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------- OBB disk images (frame.obb_mount_emul)
|
||||
# Some games mount their OBBs as FAT disk images (StorageManager.mountObb; jobb-made, e.g. GRID Legends, GitHub #308).
|
||||
# Lepton has no loop devices, so with the patch the game reads a folder with the image's name instead: each image is
|
||||
# unpacked here into `obb/<name>/` (a marker file inside keeps the image's size, so the PC's "already on the Frame"
|
||||
# check and finalize's data check still see `<name>` = that size) and the image file is removed.
|
||||
OBB_IMAGE_PATCH = "frame.obb_mount_emul"
|
||||
OBB_MARKER = ".frameport-obb-image.json"
|
||||
OBB_UNPACKING = ".fp-unpack"
|
||||
OBB_FOOTER_SIG = 0x01059983 # Android ObbFile footer signature (last 4 bytes), jobb
|
||||
OBB_SALTED = 1 # footer flag: the image is encrypted (jobb -k: PBKDF2 + Twofish), not supported
|
||||
|
||||
|
||||
class FatImage:
|
||||
"""Read-only FAT12/16/32 reader for OBB images (stdlib only; the image is untrusted: names are checked, cluster
|
||||
chains are bounded and loop-checked)."""
|
||||
|
||||
def __init__(self, path):
|
||||
self.f = open(path, "rb")
|
||||
try:
|
||||
self._parse()
|
||||
except Exception:
|
||||
self.f.close()
|
||||
raise
|
||||
|
||||
def _parse(self):
|
||||
self.f.seek(0, 2)
|
||||
size = self.f.tell()
|
||||
self.f.seek(0)
|
||||
bs = self.f.read(512)
|
||||
if len(bs) < 512 or bs[510:512] != b"\x55\xaa":
|
||||
raise ValueError("not a FAT image (no boot sector signature)")
|
||||
bps, spc, res, nfat, root_n, tot16, _media, fat16 = struct.unpack_from("<HBHBHHBH", bs, 11)
|
||||
tot32, fat32 = struct.unpack_from("<I", bs, 32)[0], struct.unpack_from("<I", bs, 36)[0]
|
||||
if bps not in (512, 1024, 2048, 4096) or spc == 0 or spc & (spc - 1) or not res or not nfat:
|
||||
raise ValueError("not a FAT image (bad BIOS parameter block)")
|
||||
fat_sectors = fat16 or fat32
|
||||
total = tot16 or tot32
|
||||
root_sectors = (root_n * 32 + bps - 1) // bps
|
||||
self.data_off = (res + nfat * fat_sectors + root_sectors) * bps
|
||||
self.cluster = bps * spc
|
||||
clusters = (total * bps - self.data_off) // self.cluster
|
||||
if fat_sectors == 0 or total * bps > size + bps or clusters <= 0:
|
||||
raise ValueError("not a FAT image (sizes don't fit the file)")
|
||||
self.bits = 12 if clusters < 4085 else 16 if clusters < 65525 else 32
|
||||
self.max_cluster = clusters + 1
|
||||
self.f.seek(res * bps)
|
||||
self.fat = self.f.read(fat_sectors * bps)
|
||||
if self.bits == 32:
|
||||
self.root = struct.unpack_from("<I", bs, 44)[0]
|
||||
self.root_region = None
|
||||
else:
|
||||
self.root = 0
|
||||
self.root_region = ((res + nfat * fat_sectors) * bps, root_sectors * bps)
|
||||
|
||||
def close(self):
|
||||
self.f.close()
|
||||
|
||||
def next_cluster(self, c):
|
||||
if self.bits == 32:
|
||||
return struct.unpack_from("<I", self.fat, 4 * c)[0] & 0x0FFFFFFF if 4 * c + 4 <= len(self.fat) else None
|
||||
if self.bits == 16:
|
||||
return struct.unpack_from("<H", self.fat, 2 * c)[0] if 2 * c + 2 <= len(self.fat) else None
|
||||
off = c + c // 2
|
||||
if off + 2 > len(self.fat):
|
||||
return None
|
||||
v = struct.unpack_from("<H", self.fat, off)[0]
|
||||
return v >> 4 if c & 1 else v & 0xFFF
|
||||
|
||||
def chain(self, first, limit):
|
||||
"""Clusters of a chain, at most `limit` (loops and out-of-range links are errors)."""
|
||||
end = {12: 0xFF8, 16: 0xFFF8, 32: 0x0FFFFFF8}[self.bits]
|
||||
out, seen, c = [], set(), first
|
||||
while 2 <= c < end and len(out) < limit:
|
||||
if c > self.max_cluster or c in seen:
|
||||
raise ValueError(f"broken cluster chain at {c}")
|
||||
seen.add(c)
|
||||
out.append(c)
|
||||
c = self.next_cluster(c)
|
||||
if c is None:
|
||||
raise ValueError("cluster chain runs past the FAT")
|
||||
return out
|
||||
|
||||
def runs(self, first, size):
|
||||
"""(image offset, length) of the contiguous pieces holding `size` bytes from cluster `first`."""
|
||||
need = (size + self.cluster - 1) // self.cluster
|
||||
clusters = self.chain(first, need) if size else []
|
||||
if len(clusters) < need:
|
||||
raise ValueError("a file is longer than its cluster chain")
|
||||
runs = []
|
||||
for c in clusters:
|
||||
off = self.data_off + (c - 2) * self.cluster
|
||||
if runs and runs[-1][0] + runs[-1][1] == off:
|
||||
runs[-1][1] += self.cluster
|
||||
else:
|
||||
runs.append([off, self.cluster])
|
||||
left, out = size, []
|
||||
for off, n in runs:
|
||||
out.append((off, min(n, left)))
|
||||
left -= min(n, left)
|
||||
return out
|
||||
|
||||
def read_dir(self, first):
|
||||
if first == 0 and self.root_region:
|
||||
self.f.seek(self.root_region[0])
|
||||
return self.f.read(self.root_region[1])
|
||||
data = bytearray()
|
||||
for off, n in self.runs(first, len(self.chain(first, 1 << 16)) * self.cluster):
|
||||
self.f.seek(off)
|
||||
data += self.f.read(n)
|
||||
return bytes(data)
|
||||
|
||||
def entries(self, first):
|
||||
"""(name, is_dir, first cluster, size) of a directory's entries (long names preferred; volume labels,
|
||||
deleted entries, '.' and '..' skipped)."""
|
||||
data, lfn = self.read_dir(first), []
|
||||
for i in range(0, len(data) - 31, 32):
|
||||
e = data[i:i + 32]
|
||||
if e[0] == 0:
|
||||
break
|
||||
if e[0] == 0xE5:
|
||||
lfn = []
|
||||
continue
|
||||
if e[11] == 0x0F:
|
||||
lfn.insert(0, e[1:11] + e[14:26] + e[28:32])
|
||||
continue
|
||||
if lfn:
|
||||
name = b"".join(lfn).decode("utf-16-le", "replace").split("\0")[0]
|
||||
else:
|
||||
base = e[0:8].replace(b"\x05", b"\xe5", 1).decode("latin-1").rstrip()
|
||||
ext = e[8:11].decode("latin-1").rstrip()
|
||||
if e[12] & 0x08:
|
||||
base = base.lower()
|
||||
if e[12] & 0x10:
|
||||
ext = ext.lower()
|
||||
name = base + ("." + ext if ext else "")
|
||||
lfn = []
|
||||
if e[11] & 0x08 or name in (".", ".."):
|
||||
continue
|
||||
cl = struct.unpack_from("<H", e, 20)[0] << 16 | struct.unpack_from("<H", e, 26)[0]
|
||||
if self.bits != 32:
|
||||
cl &= 0xFFFF
|
||||
yield name, bool(e[11] & 0x10), cl, struct.unpack_from("<I", e, 28)[0]
|
||||
|
||||
def walk(self, first=None, prefix="", depth=0):
|
||||
"""(relative path, is_dir, first cluster, size) of every entry, parents before children."""
|
||||
if depth > 64:
|
||||
raise ValueError("directories nested too deep")
|
||||
for name, is_dir, cl, size in self.entries(self.root if first is None else first):
|
||||
if not name or any(c in name for c in "/\\\0") or name.strip(". ") == "":
|
||||
raise ValueError(f"unsafe file name in the image: {name!r}")
|
||||
rel = prefix + name
|
||||
yield rel, is_dir, cl, size
|
||||
if is_dir:
|
||||
if cl < 2 or cl == self.root:
|
||||
raise ValueError(f"directory {rel} points back at the root")
|
||||
yield from self.walk(cl, rel + "/", depth + 1)
|
||||
|
||||
|
||||
def obb_footer(path):
|
||||
"""Android's OBB footer (jobb): {package, version, flags}, or None (an image without one is unpacked too)."""
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
f.seek(0, 2)
|
||||
size = f.tell()
|
||||
if size < 41:
|
||||
return None
|
||||
f.seek(size - 8)
|
||||
n, sig = struct.unpack("<II", f.read(8))
|
||||
if sig != OBB_FOOTER_SIG or not 33 <= n <= min(size - 8, 4096):
|
||||
return None
|
||||
f.seek(size - 8 - n)
|
||||
ft = f.read(n)
|
||||
_ver, pkg_ver, flags = struct.unpack_from("<III", ft, 0)
|
||||
name_len = struct.unpack_from("<I", ft, 20)[0]
|
||||
return {"version": pkg_ver, "flags": flags, "package": ft[24:24 + name_len].decode("utf-8", "replace")}
|
||||
except (OSError, struct.error):
|
||||
return None
|
||||
|
||||
|
||||
def is_obb_image(path):
|
||||
"""A FAT disk image (what StorageManager.mountObb mounts), not an archive or asset file the game reads itself."""
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
bs = f.read(512)
|
||||
except OSError:
|
||||
return False
|
||||
return (len(bs) == 512 and bs[510:512] == b"\x55\xaa" and bs[0] in (0xEB, 0xE9)
|
||||
and (bs[54:59] in (b"FAT12", b"FAT16") or bs[82:87] == b"FAT32"))
|
||||
|
||||
|
||||
def unpack_obb_image(path, dest):
|
||||
"""Unpack the FAT image `path` into the new folder `dest`; returns (files, bytes)."""
|
||||
footer = obb_footer(path)
|
||||
if footer and footer["flags"] & OBB_SALTED:
|
||||
raise AgentError(f"{os.path.basename(path)} is an encrypted OBB image; FramePort can't unpack it")
|
||||
files = total = 0
|
||||
try:
|
||||
fs = FatImage(path)
|
||||
except ValueError as exc:
|
||||
raise AgentError(f"{os.path.basename(path)}: {exc}") from None
|
||||
try:
|
||||
os.makedirs(dest)
|
||||
real_dest = os.path.realpath(dest)
|
||||
for rel, is_dir, cl, size in fs.walk():
|
||||
target = os.path.join(dest, *rel.split("/"))
|
||||
if not os.path.realpath(target).startswith(real_dest + os.sep):
|
||||
raise AgentError(f"refusing a path outside the folder: {rel}")
|
||||
if is_dir:
|
||||
os.makedirs(target, exist_ok=True)
|
||||
continue
|
||||
with open(target, "xb") as out:
|
||||
for off, n in fs.runs(cl, size):
|
||||
fs.f.seek(off)
|
||||
while n:
|
||||
chunk = fs.f.read(min(n, 8 << 20))
|
||||
if not chunk:
|
||||
raise AgentError(f"{os.path.basename(path)} ends inside {rel}")
|
||||
out.write(chunk)
|
||||
n -= len(chunk)
|
||||
files += 1
|
||||
total += size
|
||||
except ValueError as exc:
|
||||
raise AgentError(f"{os.path.basename(path)}: {exc}") from None
|
||||
finally:
|
||||
fs.close()
|
||||
return files, total
|
||||
|
||||
|
||||
def obb_unpacked_size(folder):
|
||||
"""The image size recorded in an unpacked image's folder, else None."""
|
||||
try:
|
||||
with open(os.path.join(folder, OBB_MARKER)) as f:
|
||||
size = json.load(f).get("size")
|
||||
return size if isinstance(size, int) else None
|
||||
except (OSError, ValueError, AttributeError):
|
||||
return None
|
||||
|
||||
|
||||
def obb_manifest(top):
|
||||
"""rel path -> size of the game data in an obb folder; an unpacked image's folder counts as the image file."""
|
||||
out = {}
|
||||
for root, dirs, files in os.walk(top):
|
||||
keep = []
|
||||
for d in dirs:
|
||||
p = os.path.join(root, d)
|
||||
size = obb_unpacked_size(p)
|
||||
if size is not None and not d.endswith(OBB_UNPACKING):
|
||||
out[os.path.relpath(p, top)] = size
|
||||
elif size is None and not d.endswith(OBB_UNPACKING):
|
||||
keep.append(d)
|
||||
dirs[:] = keep
|
||||
for name in files:
|
||||
if not name.endswith(".part"):
|
||||
p = os.path.join(root, name)
|
||||
out[os.path.relpath(p, top)] = os.path.getsize(p)
|
||||
return out
|
||||
|
||||
|
||||
def finish_obb_unpacks(obb):
|
||||
"""An unpack interrupted after its image was removed only needs its folder's final name; others are dropped."""
|
||||
for root, dirs, _files in os.walk(obb):
|
||||
for d in list(dirs):
|
||||
tmp = os.path.join(root, d)
|
||||
if not d.endswith(OBB_UNPACKING):
|
||||
if obb_unpacked_size(tmp) is not None:
|
||||
dirs.remove(d)
|
||||
continue
|
||||
dirs.remove(d)
|
||||
final = tmp[:-len(OBB_UNPACKING)]
|
||||
if obb_unpacked_size(tmp) is not None and not os.path.exists(final):
|
||||
os.rename(tmp, final)
|
||||
else:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
def cmd_unpack_obb(args):
|
||||
"""frame.obb_mount_emul: without `image`, the game's OBB disk images still to unpack ({pending: [rel, ...]});
|
||||
with `image` (one of them), unpack it into a folder of the same name and remove the image."""
|
||||
pkg = check_pkg(args["package"])
|
||||
dep = deployment(pkg)
|
||||
if not dep:
|
||||
raise AgentError(f"{pkg} is not installed")
|
||||
obb = os.path.join(dep["base"], "lepton-app", "obb")
|
||||
finish_obb_unpacks(obb)
|
||||
pending = []
|
||||
for root, dirs, files in os.walk(obb):
|
||||
dirs[:] = [d for d in dirs if obb_unpacked_size(os.path.join(root, d)) is None]
|
||||
for name in files:
|
||||
p = os.path.join(root, name)
|
||||
if name.lower().endswith(".obb") and is_obb_image(p):
|
||||
pending.append(os.path.relpath(p, obb))
|
||||
image = args.get("image")
|
||||
if image is None:
|
||||
return {"pending": sorted(pending)}
|
||||
if image not in pending:
|
||||
raise AgentError(f"{image} is not an OBB disk image waiting to be unpacked")
|
||||
if container_running(dep["appid"]):
|
||||
raise AgentError(f"{dep.get('title', pkg)} is running on the Frame. Close it first.")
|
||||
src = os.path.join(obb, image)
|
||||
tmp = src + OBB_UNPACKING
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
size = os.path.getsize(src)
|
||||
if shutil.disk_usage(obb).free < size + (512 << 20):
|
||||
raise AgentError(f"not enough space on the Frame to unpack {image} ({size / 2**30:.1f} GiB needed)")
|
||||
started = time.time()
|
||||
try:
|
||||
files, total = unpack_obb_image(src, tmp)
|
||||
with open(os.path.join(tmp, OBB_MARKER), "w") as f:
|
||||
json.dump({"size": size, "footer": obb_footer(src), "files": files, "bytes": total,
|
||||
"time": time.time()}, f)
|
||||
except BaseException:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
raise
|
||||
os.remove(src)
|
||||
os.rename(tmp, src)
|
||||
return {"image": image, "files": files, "bytes": total, "seconds": round(time.time() - started, 1)}
|
||||
|
||||
|
||||
def pcvr_pids(base):
|
||||
"""Processes of a PC VR game (the Proton/Wine command lines contain its install folder)."""
|
||||
p = run(["pgrep", "-f", re.escape(base.rstrip("/") + "/")])
|
||||
|
||||
@@ -365,6 +365,12 @@ signatures:
|
||||
diagnosis: The game's Vivox voice-chat SDK calls Android 12 audio-routing methods (AudioManager communication devices) without checking the Android version; Lepton runs Android 11, so the app crashes (for example Green Hell VR). The patch makes Vivox's audio-route check return early (voice chat keeps the default route).
|
||||
suggest: [frame.vivox_audio_route]
|
||||
supersedes: [java-crash]
|
||||
- id: obb-mount-failed
|
||||
pattern: "Can't mount OBB \\S+ \\(state=2[015]\\)|StorageManagerService: Couldn't mount OBB"
|
||||
severity: fatal
|
||||
diagnosis: The game mounts its OBB files as disk images (Android's StorageManager.mountObb, for example GRID Legends). Lepton has no loop devices, so the mount fails and the game quits. The patch unpacks the images on the Frame after the install and makes the game read those folders instead of mounting (known OBB helpers only; others need support in FramePort).
|
||||
suggest: [frame.obb_mount_emul]
|
||||
supersedes: [java-crash]
|
||||
- id: lepton-data-link-failed
|
||||
pattern: "ln: cannot create symbolic link from '[^']*' to '/data/data/|Couldn't create directory for SharedPreferences file /data/user/0/"
|
||||
severity: fatal
|
||||
|
||||
+16
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
# Patches
|
||||
|
||||
Every patch and game setting FramePort knows: 169 entries in 5 groups. A game's recipe is its chosen patches and settings; tested games get theirs from the catalog ([GAMES.md](GAMES.md)), and other games get the patches FramePort suggests from its analysis. You change them on the game page under Customize.
|
||||
Every patch and game setting FramePort knows: 170 entries in 5 groups. A game's recipe is its chosen patches and settings; tested games get theirs from the catalog ([GAMES.md](GAMES.md)), and other games get the patches FramePort suggests from its analysis. You change them on the game page under Customize.
|
||||
|
||||
Each entry says what the patch does, whether it changes the APK or only the install, when FramePort suggests it, its options, the catalog games whose recipe names it and the launch-test signatures that suggest it. Patches that are on by default are on for every game, so the catalog rarely names them.
|
||||
|
||||
@@ -11,7 +11,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
## Groups
|
||||
|
||||
- [Patches from OVRPort](#group-overport) (25)
|
||||
- [Steam Frame patches](#group-frame) (55)
|
||||
- [Steam Frame patches](#group-frame) (56)
|
||||
- [Game settings](#group-adapter) (68)
|
||||
- [Files and settings on the Frame](#group-device) (8)
|
||||
- [PC VR (Proton and Revive)](#group-pcvr) (13)
|
||||
@@ -64,6 +64,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [Declare Meta-only permissions](#frame-meta_permissions) | `frame.meta_permissions` | Steam Frame patches | 2 |
|
||||
| [Meta XR Audio: skip Quest telemetry class](#frame-metaxr_telemetry) | `frame.metaxr_telemetry` | Steam Frame patches | default |
|
||||
| [Clear android:debuggable](#frame-nodebug) | `frame.nodebug` | Steam Frame patches | 3 |
|
||||
| [OBB disk images: unpack instead of mounting](#frame-obb_mount_emul) | `frame.obb_mount_emul` | Steam Frame patches | |
|
||||
| [Oculus OS telemetry class stubs](#frame-oculusos) | `frame.oculusos` | Steam Frame patches | 15 |
|
||||
| [Fix crash when the game prepares the microphone](#frame-ovr_microphone) | `frame.ovr_microphone` | Steam Frame patches | |
|
||||
| [Keep the same Meta user between starts](#frame-ovr_stable_user) | `frame.ovr_stable_user` | Steam Frame patches | default |
|
||||
@@ -740,6 +741,19 @@ Stops strict debugging checks that make some games quit.
|
||||
|
||||
> OVRPort marks apps debuggable; that turns on CheckJNI, which aborts some Unreal games on sloppy JNI calls ('JNI DETECTED ERROR', for example 'GetStringUTFChars ... NULL' in Time Stall). Clear it for those.
|
||||
|
||||
### <a id="frame-obb_mount_emul"></a>OBB disk images: unpack instead of mounting
|
||||
|
||||
Lets games whose data files are disk images start on the Frame (they'd quit with "Can't mount OBB"), for example GRID Legends. The images are unpacked on the Frame.
|
||||
|
||||
- `frame.obb_mount_emul` · experimental · also for 2D apps
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** The game mounts its OBB files as disk images, which Lepton can't do: unpack them on the Frame and let the game read the folders.
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `obb-mount-failed` (fatal): The game mounts its OBB files as disk images (Android's StorageManager.mountObb, for example GRID Legends).
|
||||
- **Source:** [patches/frame/obb_mount.py](../src/frameport/patches/frame/obb_mount.py)
|
||||
|
||||
> Some games keep their data in OBB files that Android mounts as disk images (for example GRID Legends). Lepton can't mount disk images, so these games quit at start ("Can't mount OBB"). FramePort unpacks the images on the Frame after the install (into folders with the same names, so the data takes its space only once) and changes the game's OBB code so it uses those folders instead of mounting.
|
||||
|
||||
### <a id="frame-oculusos"></a>Oculus OS telemetry class stubs
|
||||
|
||||
Provides stand-ins for Quest system calls some games make at start.
|
||||
|
||||
@@ -136,6 +136,7 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
|
||||
| SDL / LÖVE app crashes at start: `NullPointerException … ClipboardManager.addPrimaryClipChangedListener` in `SDLClipboardHandler.<init>` (for example Dramatic Shape) | Lepton's Android has no clipboard service (`service check clipboard`: not found) | `frame.sdl_clipboard` (that call → nops in classes.dex, in place, `apk/dex.py`); reproduced and verified with LÖVE for Android 11.5 |
|
||||
| Godot 4 app crashes at start: `java.lang.NullPointerException: null cannot be cast to non-null type android.content.ClipboardManager` at `org.godotengine.godot.Godot.<init>` (Godot 4.3/4.4; 4.2 when the clipboard is first used, in `Godot$mClipboard$2.invoke`) (for example EndoparasiticVR, GitHub #134); triage `godot-no-clipboard` | Lepton's Android has no clipboard service and Godot's Kotlin code casts it with a non-null `as ClipboardManager` (`Intrinsics.checkNotNull` before the check-cast). Godot 4.1 (Java) and 4.5+ (`as?`) don't throw | `frame.godot_clipboard` (suggested by analysis `godot_clipboard`): that checkNotNull call → nops in classes.dex (check-cast accepts null), and Godot's getClipboard/hasClipboard/setClipboard return at once ("" / false / nothing; `Dex.return_early`), in place; matched exactly, other dex files untouched. Checked against the Maven godot-lib 4.2.2/4.3.0/4.4.1 and the 4.3/4.4.1 release export templates (D8 re-reads the result); not yet run on the device |
|
||||
| Game crashes at start: `java.lang.NoSuchMethodError: No virtual method getAvailableCommunicationDevices()… in class Landroid/media/AudioManager` at `com.vivox.sdk.AudioChangeListener.checkAudioRouteAndApplyChanges` (for example Green Hell VR, GitHub #101); triage `vivox-api31` | Newer Vivox (voice chat) builds call Android 12 audio-routing methods without a version check; Lepton is Android 11. Older Vivox builds (Eleven Table Tennis, BattleSisters) don't have that code | `frame.vivox_audio_route` (suggested by analysis `vivox_api31`): every AudioChangeListener method that calls one of them returns at once (`Dex.return_early`: return-void / `const/4 v0, 0; return v0`, in place); voice chat keeps the default audio route |
|
||||
| Game quits at start: `java.lang.RuntimeException: Can't mount OBB DataSet0.obb (state=21)` from `androidutils.OBBHandler`, with `vold: Failed to open loop-control` / `Failed to open device-mapper` (GitHub #308, GRID Legends); triage `obb-mount-failed` | The game's OBBs are FAT disk images (Google's jobb tool, footer signature 0x01059983) that it mounts with `StorageManager.mountObb` and reads below `getMountedObbPath`. vold needs loop devices + device-mapper, which Lepton's container doesn't have | `frame.obb_mount_emul` (experimental; analysis `obb_mount`): the agent (`unpack_obb`, called by the installer after finalize/configure, one image per call) unpacks each image into a folder with the image's own name (`obb/DataSet0.obb/`, marker `.frameport-obb-image.json` keeps the image size, so uploads/data checks still see the image) and removes the image; the dex rewrite (`Dex.rewrite_start`) makes Codemasters' `OBBHandler.mountOBB` report MOUNTED at once and `getMountedOBBFullPath` return the OBB's own path (= that folder). Other mountObb callers are only detected (`obb_mount: ["other"]`) and need a handler entry. Encrypted (jobb -k) images are refused. GRID 1.0.0.41 then crashed in `getOBBMountPoints()+420` (SIGSEGV, fault address = path text): libgrid frees the path string from libandroid_utils with its own operator delete, which only goes unnoticed for short (in-object) strings like Quest's `/mnt/obb/…`; the patch also takes out that free in that build (`NATIVE_FIXES`, window SHA-256 checked). Headless 2026-10-16 with DataSet0 only: "OBB DataSet0.obb mounted", OpenXR instance + swapchains, then UI shutdown/crash 3 s later (the other 16 OBBs were missing) |
|
||||
| A game on a microSD card or another drive doesn't start, loses its saves or loops at start; launch.log has `ln: cannot create symbolic link from 'Frame' to '/data/data/<pkg>'` or `Couldn't create directory for SharedPreferences file /data/user/0/<pkg>/…` (GitHub #213/#214/#215/#221: a card named "Steam Frame 512g"); triage `lepton-data-link-failed` | Valve's Lepton pastes `STEAM_COMPAT_DATA_PATH` unquoted into `sh -c "ln -s …/internal/<pkg> /data/data/<pkg>"` (liblepton mounting.sh) and APK paths into unquoted substitutions, so a path with a space never gets the app's internal storage | agent v83: the Quest launcher links the game folder to `~/.local/share/frameport/lepton-paths/<pkg>` at every start and gives Lepton that path (`app_dir_lines`); `upgrade_launchers` converts existing launchers at the next connection; deployment.json keeps the real path |
|
||||
| Start never finishes, 100 % CPU, launch.log of gigabytes repeating `EOSSDK.createKeychain` / `resetKeychain` (Beyond Sandbox, GitHub #214); triage `eos-keychain-loop` | Epic Online Services' Java keychain can't write its SharedPreferences and resets/creates without end (seen when the app had no internal storage, row above) | fix the storage first; `frame.eos_keychain_loop` (opt-in, offered by triage; analysis `eos_keychain`) makes `EOSSDK.resetKeychain` return at once (`Dex.return_early`) |
|
||||
| Unity game freezes on its loading screen ~10-15 s in (Yupitergrad, GitHub #235); launch.log has `GrantPermissionsActivity: Permission grant result … permission=android.permission.BLUETOOTH_CONNECT`, then OVRPlugin `SetupDisplayObjects(): Unable to find EGLConfig for configId 0` and no more frames; triage `bhaptics-permission-prompt` | bHaptics' vest SDK (`com.bhaptics.commons.PermissionUtils.requestBluetoothPermission`) asks for Android 12's BLUETOOTH_CONNECT; on Lepton's Android 11 it can't be granted at install, so Android's permission screen takes the foreground for a moment, the game pauses/resumes and OVRPlugin's GLES compositor stops drawing | `frame.bhaptics_permission` (suggested by analysis `bhaptics_permission`): the request returns at once (`Dex.return_early`) |
|
||||
|
||||
@@ -474,6 +474,11 @@
|
||||
"title": "Clear android:debuggable",
|
||||
"group": "Steam Frame patches"
|
||||
},
|
||||
"frame.obb_mount_emul": {
|
||||
"anchor": "frame-obb_mount_emul",
|
||||
"title": "OBB disk images: unpack instead of mounting",
|
||||
"group": "Steam Frame patches"
|
||||
},
|
||||
"frame.oculusos": {
|
||||
"anchor": "frame-oculusos",
|
||||
"title": "Oculus OS telemetry class stubs",
|
||||
|
||||
@@ -43,7 +43,9 @@ IL2CPP_METADATA = "assets/bin/Data/Managed/Metadata/global-metadata.dat"
|
||||
# 17: headset (the Meta headset a VR app was made for: Gear VR / Go games run through frame.a32_bridge) (2026-10)
|
||||
# 18: headset "quest" also for 32-bit apps that require 6DoF head tracking (Santa Cruz builds, e.g. Sports
|
||||
# Scramble: Touch controllers on the a32 bridge) (2026-10)
|
||||
ANALYSIS_VERSION = 19
|
||||
# 20: obb_mount (the game mounts its OBBs as disk images through StorageManager.mountObb, which Lepton can't:
|
||||
# frame.obb_mount_emul, GitHub #308) (2026-10)
|
||||
ANALYSIS_VERSION = 20
|
||||
|
||||
|
||||
# Android versions by API level (for messages); the Frame's Lepton container runs Android 11 (API 30)
|
||||
@@ -249,6 +251,12 @@ def analyze(path: Path, deep: bool = True, data_bytes: int | None = None) -> Ana
|
||||
for d in dexes)
|
||||
# video through Android's decoders (frame.hw_video_decode): Java MediaCodec, ExoPlayer/Media3, or libVLC
|
||||
media_codec = any(m in d for d in dexes for m in MEDIA_CODEC_MARKERS) or "libvlc.so" in libs
|
||||
# OBBs mounted as disk images (StorageManager.mountObb; Lepton has no loop devices): frame.obb_mount_emul
|
||||
from ..patches.frame.obb_mount import handler_classes
|
||||
|
||||
obb_mount = sorted({c for d in dexes for c in handler_classes(d)}) or (
|
||||
["other"] if any(b"Landroid/os/storage/StorageManager;" in d and b"\x08mountObb\0" in d for d in dexes)
|
||||
else [])
|
||||
from ..patches.frame.unreal_obb_check import has_delivery_check
|
||||
|
||||
# Includes obfuscated DownloaderActivity predicates; CRC verification is a separate manifest switch.
|
||||
@@ -381,6 +389,7 @@ def analyze(path: Path, deep: bool = True, data_bytes: int | None = None) -> Ana
|
||||
"sdl_java": sdl_java,
|
||||
"godot_clipboard": godot_clipboard,
|
||||
"vivox_api31": vivox_api31,
|
||||
"obb_mount": obb_mount,
|
||||
"eos_keychain": eos_keychain,
|
||||
"bhaptics_permission": bhaptics_permission,
|
||||
"media_codec": media_codec,
|
||||
|
||||
@@ -67,6 +67,17 @@ class Dex:
|
||||
cls, _proto, name = struct.unpack_from("<HHI", self.data, self.methods_off + 8 * idx)
|
||||
return self.type_name(cls), self.string(name)
|
||||
|
||||
def shorty(self, idx: int) -> str:
|
||||
"""The method's shorty descriptor (return type + parameter types, one letter each: "VIL" = void f(int, Obj))."""
|
||||
proto = struct.unpack_from("<HHI", self.data, self.methods_off + 8 * idx)[1]
|
||||
protos_off = struct.unpack_from("<I", self.data, 76)[0]
|
||||
return self.string(struct.unpack_from("<I", self.data, protos_off + 12 * proto)[0])
|
||||
|
||||
def code_header(self, code_off: int) -> tuple[int, int, int]:
|
||||
"""(registers_size, ins_size, insns_size) of a code item; the ins are the last registers."""
|
||||
regs, ins = struct.unpack_from("<2H", self.data, code_off)
|
||||
return regs, ins, struct.unpack_from("<I", self.data, code_off + 12)[0]
|
||||
|
||||
def return_type(self, idx: int) -> str:
|
||||
proto = struct.unpack_from("<HHI", self.data, self.methods_off + 8 * idx)[1]
|
||||
protos_off = struct.unpack_from("<I", self.data, 76)[0]
|
||||
@@ -168,6 +179,12 @@ class Dex:
|
||||
new = [CONST_STRING_V0, 0, RETURN_OBJECT_V0] # string ids are sorted: "" is always the first
|
||||
else:
|
||||
return False
|
||||
return self.rewrite_start(code_off, new)
|
||||
|
||||
def rewrite_start(self, code_off: int, new: list[int]) -> bool:
|
||||
"""Replace a method's first instruction(s) with `new` (code units ending in a return, so the rest of the
|
||||
method becomes unreachable), padded with nops to the next instruction boundary. Nothing moves. False if the
|
||||
method already starts with `new` or its first instructions can't be decoded / are shorter than `new`."""
|
||||
start = code_off + 16
|
||||
insns_n = struct.unpack_from("<I", self.data, code_off + 12)[0]
|
||||
if list(struct.unpack_from(f"<{min(len(new), insns_n)}H", self.data, start)) == new:
|
||||
@@ -182,6 +199,14 @@ class Dex:
|
||||
struct.pack_into(f"<{width}H", self.data, start, *new, *([0] * (width - len(new))))
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def invoke_static(method: int, regs: list[int]) -> list[int]:
|
||||
"""invoke-static {regs}, method@ (format 35c: up to 5 registers, each v0-v15)."""
|
||||
if len(regs) > 5 or any(not 0 <= r < 16 for r in regs):
|
||||
raise ValueError("invoke-static needs at most 5 registers below v16")
|
||||
r = list(regs) + [0] * (5 - len(regs))
|
||||
return [(len(regs) << 12) | (r[4] << 8) | 0x71, method, r[0] | (r[1] << 4) | (r[2] << 8) | (r[3] << 12)]
|
||||
|
||||
def finish(self) -> bytes:
|
||||
self.data[12:32] = hashlib.sha1(bytes(self.data[32:])).digest()
|
||||
struct.pack_into("<I", self.data, 8, zlib.adler32(bytes(self.data[12:])) & 0xFFFFFFFF)
|
||||
|
||||
@@ -82,15 +82,37 @@ def update_existing(frame: Frame, entry: dict, deployment: dict, plan, recipe: R
|
||||
args.update(obb_manifest=manifest, existing_only=True, consume_artwork=False,
|
||||
apk_name=deployment.get("apk", "game.apk"))
|
||||
reporter.stage("Finalize game data")
|
||||
return frame.agent("finalize", **args)
|
||||
result = frame.agent("finalize", **args)
|
||||
unpack_obb_images(frame, entry["package"], recipe, reporter)
|
||||
return result
|
||||
reporter.stage("Apply game configuration")
|
||||
result = frame.agent("configure", **args)
|
||||
if result.get("deployment", {}).get("configuration_fp") != args["configuration_fp"]:
|
||||
raise RuntimeError("the Frame did not confirm the applied configuration")
|
||||
reporter.check("Game configuration", True, "applied; used from the game's next start")
|
||||
unpack_obb_images(frame, entry["package"], recipe, reporter)
|
||||
return result
|
||||
|
||||
|
||||
OBB_IMAGE_PATCH = "frame.obb_mount_emul"
|
||||
|
||||
|
||||
def unpack_obb_images(frame: Frame, package: str, recipe: Recipe, reporter: Reporter) -> int:
|
||||
"""frame.obb_mount_emul: the game's OBB disk images are unpacked on the Frame (agent unpack_obb), one call per
|
||||
image (each takes a while: ~2 GB of reading and writing). Returns how many were unpacked."""
|
||||
if OBB_IMAGE_PATCH not in recipe.patches:
|
||||
return 0
|
||||
pending = frame.agent("unpack_obb", package=package).get("pending") or []
|
||||
for i, image in enumerate(pending, 1):
|
||||
reporter.check_cancel()
|
||||
reporter.stage(f"Unpack game data {i}/{len(pending)}")
|
||||
reporter.progress((i - 1) / len(pending), f"Unpacking {image}")
|
||||
done = frame.agent("unpack_obb", package=package, image=image, timeout=None)
|
||||
reporter.log(f"unpacked {image}: {done['files']} files, {done['bytes'] / 2**30:.2f} GiB in "
|
||||
f"{done['seconds']} s")
|
||||
return len(pending)
|
||||
|
||||
|
||||
def local_data_manifest(data_dir: Path | None, files: list[str] | None = None) -> dict[str, int]:
|
||||
return data_manifest(data_dir, files)
|
||||
|
||||
@@ -132,8 +154,10 @@ def install(frame: Frame, plan: InstallPlan, reporter: Reporter) -> dict:
|
||||
existing = prep["existing_obb"]
|
||||
to_send = [rel for rel, size in manifest.items() if existing.get(rel) != size]
|
||||
need = sum(manifest[r] for r in to_send) + (0 if prep["same_apk"] else plan.apk.stat().st_size)
|
||||
if need > prep["free_bytes"] - (1 << 30):
|
||||
raise RuntimeError(f"not enough space on the Frame: need {need / 2**30:.1f} GiB + 1 GiB headroom, "
|
||||
# unpacking OBB disk images (frame.obb_mount_emul) holds one image twice for a while
|
||||
space = need + (max(manifest[r] for r in to_send) if OBB_IMAGE_PATCH in plan.recipe.patches and to_send else 0)
|
||||
if space > prep["free_bytes"] - (1 << 30):
|
||||
raise RuntimeError(f"not enough space on the Frame: need {space / 2**30:.1f} GiB + 1 GiB headroom, "
|
||||
f"have {prep['free_bytes'] / 2**30:.1f} GiB")
|
||||
total = max(need, 1)
|
||||
sent = 0
|
||||
@@ -182,6 +206,7 @@ def install(frame: Frame, plan: InstallPlan, reporter: Reporter) -> dict:
|
||||
preserve_data=plan.apk_only, consume_artwork=artwork_manifest is not None, **config,
|
||||
)
|
||||
reporter.log(f"installed at {result['base']} (Steam shortcut id {result['appid']})")
|
||||
unpack_obb_images(frame, plan.package, plan.recipe, reporter)
|
||||
log_controller_models(result.get("controller_models"), reporter)
|
||||
log_config_sync(result.get("config_sync"), reporter)
|
||||
return result
|
||||
|
||||
@@ -826,6 +826,7 @@
|
||||
"Lets games built on Meta's oldest VR interface run on the Frame.": "",
|
||||
"Lets games made for Oculus PCs run on SteamVR.": "",
|
||||
"Lets games start that would stop at Meta's avatars (for example BlazeRush). No Meta avatars.": "",
|
||||
"Lets games whose data files are disk images start on the Frame (they'd quit with \"Can't mount OBB\"), for example GRID Legends. The images are unpacked on the Frame.": "",
|
||||
"Lets games with a verified older Lua engine get past a startup crash on the Frame.": "",
|
||||
"Lets games with Vivox voice chat start on the Frame (they'd crash on an Android 12 audio call).": "",
|
||||
"Lets games written for newer OpenXR versions run on the Frame.": "",
|
||||
@@ -1005,6 +1006,7 @@
|
||||
"Now {scale:.0%}; {new:.0%} after restarting FramePort.": "",
|
||||
"nowhere": "",
|
||||
"NPU": "",
|
||||
"OBB disk images: unpack instead of mounting": "",
|
||||
"Oculus game: it needs Revive, which doesn't run on the Frame. Play it on this PC.": "",
|
||||
"Oculus Go": "",
|
||||
"Oculus Go (32-bit)": "",
|
||||
@@ -1408,6 +1410,7 @@
|
||||
"Some engines leave the pNext of unused Vulkan attachment references uninitialized. The Frame's driver never reads it, but Lepton always loads Steam's Fossilize shader-cache layer, which follows it and crashes on the first frame (SIGSEGV in libVkLayer_fossilize.so from FVulkanRenderPass, for example Deadpool VR). Loads Vulkan through a small shim that keeps valid pointers and drops only unreadable ones or ones pointing at the wrong structure type. It also drops a depth resolve named in a subpass without a depth attachment, which crashes the Frame's driver at the first render pass (for example Metro Awakening).": "",
|
||||
"Some game folders are already set up for SteamVR: a Revive copy (LibRevive64.dll) next to the game, started by a small loader DLL (for example xinput1_3.dll) that Windows loads from the game's folder. Then FramePort starts the game program directly — like double-clicking it — instead of through its own Revive (two Revives conflict; for example the Oculus Platform check then fails). On the Frame, Wine is told to use those loader DLLs from the game folder instead of its own.": "",
|
||||
"Some games keep content in separate files next to their OBB (for example Star Wars: Tales from the Galaxy's Edge: its seasons and sound banks) and ask Meta's platform for them as asset files. OVRPort's platform loader doesn't know them, so the game waits for content that never arrives (black screen after loading). Lists the content files shipped with the game's data (*.pak) as installed and answers their download request at once with the file's path; nothing is copied or downloaded. Shares the language-pack library.": "",
|
||||
"Some games keep their data in OBB files that Android mounts as disk images (for example GRID Legends). Lepton can't mount disk images, so these games quit at start (\"Can't mount OBB\"). FramePort unpacks the images on the Frame after the install (into folders with the same names, so the data takes its space only once) and changes the game's OBB code so it uses those folders instead of mounting.": "",
|
||||
"Some games save their render resolution in a settings file and load it after creating the headset's eye buffers; after a resolution scale change the saved size no longer matches and the picture is displaced or stretched, with black borders (for example BlazeRush's user_config.xml). FramePort writes the values its recipe names into the game's file while the game isn't running (after an install and after a Game settings change), from the Frame's own eye-buffer size times the resolution scale: w and h = the eye buffer's width and height, for example h*16/9. Only existing entries change; the previous file is kept as <file>.frameport-backup. The size is learned from the game's last start, so a file the game wrote before is corrected from the next install or settings change on.": "",
|
||||
"Some OpenGL ES engines compile every shader for multiview (both eyes in one draw, `num_views=2`) and use the same shaders to draw into ordinary single-layer framebuffers, for example a HUD or PDA screen rendered to a texture. Quest's driver draws them anyway; the Frame's driver (Mesa) follows the OVR_multiview rule and silently drops every such draw, so those panels stay black (for example Doom3Quest's HUD and PDA). Loads OpenGL ES through a small interposer (libfpglmv.so) that draws those cases with a single-view copy of the shader program (left-eye view; uniforms copied before each such draw). Eye-buffer draws are unchanged. Logs under the tag GLMV; the setting gl_mv_debug adds error checks and counters.": "",
|
||||
"Some OpenXR games still ship Meta's VrApi loader and call it a few times without starting VrApi (for example Jurassic World Aftermath); Meta's loader then stops the game (\"vrapi_SetPropertyInt was called before vrapi_Initialize()\"). Replaces it with a library that has the same functions, each doing nothing. Only for games whose VR runs through OVRPlugin on OpenXR.": "",
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Games that mount their OBB as a disk image (GitHub #308, GRID Legends): Android's StorageManager.mountObb() asks
|
||||
vold to attach the .obb (a FAT image made with Google's jobb tool) to a loop device and mount it; the game then reads
|
||||
its data below StorageManager.getMountedObbPath(). Lepton's Android 11 has neither loop devices nor device-mapper
|
||||
(`vold: Failed to open loop-control`), so every mount fails with state 21 (ERROR_COULD_NOT_MOUNT) and the game throws
|
||||
`RuntimeException: Can't mount OBB …`.
|
||||
|
||||
FramePort mounts nothing: the agent unpacks each FAT image on the Frame into a folder *with the image's name*
|
||||
(`obb/DataSet0.obb/` instead of the file `obb/DataSet0.obb`; agent `unpack_obb`, after the install), and this patch
|
||||
rewrites the game's OBB helper so that "mounting" reports success at once and the "mount point" is that folder, i.e.
|
||||
the OBB's own path. The folder keeps the checks that look at the file working (File.exists(), the `.obb` count in the
|
||||
OBB folder), the storage cost is the data once (the image is removed after unpacking), and Lepton's read-only bind
|
||||
mount of the OBB folder shows it to the game unchanged.
|
||||
|
||||
Handlers: only Codemasters' `androidutils.OBBHandler` (GRID Legends; its native side, libandroid_utils.so, already
|
||||
accepts a synchronous MOUNTED callback: that is its "already mounted" path) is rewritten so far. Other mountObb users
|
||||
are detected (analysis `obb_mount`) and reported, but need their own handler entry (or a dex writer that can add
|
||||
methods). The edit is in place (Dex.rewrite_start): `mountOBB(name)` → `onMountOBBStateChanged(1 /* MOUNTED */,
|
||||
name); return`, `getMountedOBBFullPath(name)` → `return getOBBFullPath(name)`. unmountOBB stays: in Lepton
|
||||
isObbMounted() is false, so it already reports "not mounted" (23), which the game accepts.
|
||||
|
||||
Encrypted OBBs (jobb -k: PBKDF2 + Twofish, footer flag OBB_SALTED) are not supported; GRID's are plain FAT32 and the
|
||||
game passes no key. FAT is case-insensitive, the Frame's file systems are not: unpacked names keep the image's case
|
||||
(GRID's own index.txt uses the same spelling as its files).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from dataclasses import dataclass
|
||||
|
||||
from ...analysis import elf
|
||||
from ...apk.dex import Dex
|
||||
from ..base import ApkContext, Patch, Suggestion, register
|
||||
|
||||
MOUNTED = 1 # android.os.storage.OnObbStateChangeListener.MOUNTED
|
||||
|
||||
# class -> (mount method, mounted-path method, state callback (I,String)V, image path helper (String)String)
|
||||
HANDLERS = {
|
||||
"Landroidutils/OBBHandler;": ("mountOBB", "getMountedOBBFullPath", "onMountOBBStateChanged", "getOBBFullPath"),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NativeFix:
|
||||
"""Instruction words to replace in one known build of a library: the window [start, start + len) must hash to
|
||||
`sha256` (as shipped), else nothing is changed."""
|
||||
lib: str
|
||||
start: int # virtual address of the checked window
|
||||
length: int
|
||||
sha256: str
|
||||
edits: tuple[tuple[int, bytes, bytes], ...] # (vaddr, old word, new word)
|
||||
|
||||
|
||||
# GRID Legends 1.0.0.41 (libgrid.so BuildId 88834f67…): getOBBMountPoints() frees the std::string that
|
||||
# libandroid_utils' AndroidUtils::getMountedOBBFullPath() returns with libgrid's own operator delete, although
|
||||
# libandroid_utils (loaded first, by Java) allocated it with libc++'s operator new. On a Quest the mounted path is
|
||||
# short ("/mnt/obb/…", kept inside the string object: nothing is freed); the OBB's own path is longer, so the game
|
||||
# crashed in that delete (SIGSEGV at getOBBMountPoints+420, fault address = the path's text). The two branches into
|
||||
# the delete are taken out: 17 short strings are leaked once at start.
|
||||
NATIVE_FIXES = (
|
||||
NativeFix("libgrid.so", 0x14B7638, 0x100, "8ec37aeaa7fdd5b22200d183965dc4917a7875fd8aff0afe0ad75907d419e3f5",
|
||||
((0x14B769C, bytes.fromhex("d5040034"), bytes.fromhex("26000014")), # cbz w21, +0x98 -> b +0x98
|
||||
(0x14B7730, bytes.fromhex("95fbff35"), bytes.fromhex("1f2003d5")))), # cbnz w21, free -> nop
|
||||
)
|
||||
|
||||
|
||||
def native_fix(data: bytes, fix: NativeFix) -> bytes | None:
|
||||
"""The library with `fix` applied, or None when it isn't that build (or is already fixed)."""
|
||||
try:
|
||||
segments = elf.load_segments(data)
|
||||
except Exception: # noqa: BLE001 (not an ELF file)
|
||||
return None
|
||||
off = elf.vaddr_to_offset(segments, fix.start)
|
||||
if off is None or hashlib.sha256(data[off:off + fix.length]).hexdigest() != fix.sha256:
|
||||
return None
|
||||
out = bytearray(data)
|
||||
for va, old, new in fix.edits:
|
||||
pos = elf.vaddr_to_offset(segments, va)
|
||||
if pos is None or out[pos:pos + 4] != old:
|
||||
return None
|
||||
out[pos:pos + 4] = new
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def handler_classes(data: bytes) -> list[str]:
|
||||
"""Known OBB-mounting handler classes in one dex's bytes (cheap byte check, for analysis)."""
|
||||
return [c for c in HANDLERS if c.encode() in data and b"mountObb" in data]
|
||||
|
||||
|
||||
def patch_dex(dex: Dex) -> list[str]:
|
||||
"""Rewrite every known handler in this dex; returns 'Class.method' for each method changed ([] = nothing)."""
|
||||
done = []
|
||||
for cls, (mount, mounted_path, callback, image_path) in HANDLERS.items():
|
||||
cb = [i for i in dex.method_index(cls, callback) if dex.shorty(i) == "VIL"]
|
||||
path = [i for i in dex.method_index(cls, image_path) if dex.shorty(i) == "LL"]
|
||||
if not cb or not path:
|
||||
continue
|
||||
for idx, code in dex.class_methods(cls):
|
||||
name = dex.method(idx)[1]
|
||||
if name not in (mount, mounted_path) or dex.shorty(idx) != ("VL" if name == mount else "LL"):
|
||||
continue
|
||||
regs, ins, _n = dex.code_header(code)
|
||||
arg = regs - ins # static (String): the name is the last register
|
||||
if ins != 1 or regs < 2 or arg >= 16:
|
||||
continue
|
||||
if name == mount: # const/4 v0, #1; invoke-static {v0, vArg}, callback; return-void
|
||||
new = [0x0012 | (MOUNTED << 12)] + Dex.invoke_static(cb[0], [0, arg]) + [0x000E]
|
||||
else: # invoke-static {vArg}, image path; move-result-object v0; return-object v0
|
||||
new = Dex.invoke_static(path[0], [arg]) + [0x000C, 0x0011]
|
||||
if dex.rewrite_start(code, new):
|
||||
done.append(f"{cls[1:-1].replace('/', '.')}.{name}")
|
||||
return done
|
||||
|
||||
|
||||
class ObbMountEmulation(Patch):
|
||||
id = "frame.obb_mount_emul"
|
||||
title = "OBB disk images: unpack instead of mounting"
|
||||
description = ("Some games keep their data in OBB files that Android mounts as disk images (for example GRID "
|
||||
"Legends). Lepton can't mount disk images, so these games quit at start (\"Can't mount OBB\"). "
|
||||
"FramePort unpacks the images on the Frame after the install (into folders with the same names, "
|
||||
"so the data takes its space only once) and changes the game's OBB code so it uses those "
|
||||
"folders instead of mounting.")
|
||||
order = 44
|
||||
needs_vr = False
|
||||
experimental = True
|
||||
|
||||
def applies(self, a):
|
||||
return bool((a.extra or {}).get("obb_mount"))
|
||||
|
||||
def detect(self, a):
|
||||
found = (a.extra or {}).get("obb_mount") or []
|
||||
if any(c in HANDLERS for c in found):
|
||||
return Suggestion(True, "The game mounts its OBB files as disk images, which Lepton can't do: unpack "
|
||||
"them on the Frame and let the game read the folders.")
|
||||
return None
|
||||
|
||||
def apply(self, ctx: ApkContext) -> bool:
|
||||
changed = False
|
||||
for name in sorted(n for n in ctx.ws.names() if n.startswith("classes") and n.endswith(".dex")):
|
||||
data = ctx.ws.read(name)
|
||||
if not handler_classes(data):
|
||||
continue
|
||||
dex = Dex(data)
|
||||
done = patch_dex(dex)
|
||||
if done:
|
||||
ctx.ws.put(name, dex.finish())
|
||||
ctx.notes.append(f"{name}: OBB mounting replaced by unpacked folders in {', '.join(sorted(done))}")
|
||||
changed = True
|
||||
if changed and ctx.ws.abi == "arm64-v8a":
|
||||
for fix in NATIVE_FIXES:
|
||||
if ctx.ws.has(ctx.ws.lib(fix.lib)):
|
||||
new = native_fix(ctx.ws.read_lib(fix.lib), fix)
|
||||
if new is not None:
|
||||
ctx.ws.put(ctx.ws.lib(fix.lib), new)
|
||||
ctx.notes.append(f"{fix.lib}: the mounted OBB path is no longer freed with the game's allocator")
|
||||
return changed
|
||||
|
||||
|
||||
register(ObbMountEmulation)
|
||||
@@ -82,6 +82,8 @@ SUMMARIES = {
|
||||
"would never finish starting).",
|
||||
"frame.unity_tls_key_pool": "Lets Unity 6 games with many native libraries start when they run out of thread keys "
|
||||
"(they'd abort with \"cannot create thread specific key\"), for example Wander.",
|
||||
"frame.obb_mount_emul": "Lets games whose data files are disk images start on the Frame (they'd quit with "
|
||||
"\"Can't mount OBB\"), for example GRID Legends. The images are unpacked on the Frame.",
|
||||
"frame.vivox_audio_route": "Lets games with Vivox voice chat start on the Frame (they'd crash on an Android 12 "
|
||||
"audio call).",
|
||||
"frame.unity_oculus_check": "Lets older Unity games start VR without Meta's system apps (they'd stay on a 2D "
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
"""frame.obb_mount_emul (GitHub #308, GRID Legends): games that mount their OBBs as FAT disk images. The agent unpacks
|
||||
each image into a folder with the image's name (agent unpack_obb), the dex rewrite makes the game's OBB helper report
|
||||
MOUNTED at once and use that folder as the mount point."""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from frameport.apk.dex import Dex
|
||||
from frameport.patches.frame.obb_mount import HANDLERS, ObbMountEmulation, handler_classes, patch_dex
|
||||
from frameport.validate.triage import triage
|
||||
|
||||
AGENT = Path(__file__).resolve().parents[1] / "agent" / "frameport_agent.py"
|
||||
H = "Landroidutils/OBBHandler;"
|
||||
|
||||
|
||||
def _uleb(n: int) -> bytes:
|
||||
out = bytearray()
|
||||
while True:
|
||||
b = n & 0x7F
|
||||
n >>= 7
|
||||
out.append(b | (0x80 if n else 0))
|
||||
if not n:
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def build_dex(methods):
|
||||
"""A minimal dex: methods = (class, name, shorty, registers, ins, code units or None). Types in a shorty: V void,
|
||||
I int, L java/lang/String (enough for these methods)."""
|
||||
strings: list[str] = []
|
||||
|
||||
def s(x):
|
||||
if x not in strings:
|
||||
strings.append(x)
|
||||
return strings.index(x)
|
||||
|
||||
types: list[int] = []
|
||||
|
||||
def t(x):
|
||||
i = s(x)
|
||||
if i not in types:
|
||||
types.append(i)
|
||||
return types.index(i)
|
||||
|
||||
names = {"V": "V", "I": "I", "L": "Ljava/lang/String;"}
|
||||
protos: list[tuple[int, int, tuple[int, ...]]] = []
|
||||
|
||||
def p(shorty):
|
||||
key = (s(shorty), t(names[shorty[0]]), tuple(t(names[c]) for c in shorty[1:]))
|
||||
if key not in protos:
|
||||
protos.append(key)
|
||||
return protos.index(key)
|
||||
|
||||
mids = [(t(c), p(sh), s(n)) for c, n, sh, *_ in methods]
|
||||
header = 0x70
|
||||
off = header + 4 * len(strings) + 4 * len(types) + 12 * len(protos) + 8 * len(mids)
|
||||
classes = sorted({m[0] for m in methods if m[5] is not None})
|
||||
sid_off = header
|
||||
tid_off = sid_off + 4 * len(strings)
|
||||
pid_off = tid_off + 4 * len(types)
|
||||
mid_off = pid_off + 12 * len(protos)
|
||||
cdef_off = off
|
||||
off += 32 * len(classes)
|
||||
data = bytearray()
|
||||
|
||||
def place(blob, align=1):
|
||||
while (off + len(data)) % align:
|
||||
data.append(0)
|
||||
pos = off + len(data)
|
||||
data.extend(blob)
|
||||
return pos
|
||||
|
||||
str_offs = [place(_uleb(len(x)) + x.encode() + b"\0") for x in strings]
|
||||
plists = [place(struct.pack("<I", len(ps)) + b"".join(struct.pack("<H", x) for x in ps), 4) if ps else 0
|
||||
for _s, _r, ps in protos]
|
||||
code_offs = {}
|
||||
for i, (_c, _n, _sh, regs, ins, code) in enumerate(methods):
|
||||
if code is not None:
|
||||
item = struct.pack("<4HII", regs, ins, 2, 0, 0, len(code)) + struct.pack(f"<{len(code)}H", *code)
|
||||
code_offs[i] = place(item, 4)
|
||||
class_data = {}
|
||||
for c in classes:
|
||||
idxs = [i for i, m in enumerate(methods) if m[0] == c and m[5] is not None]
|
||||
blob = _uleb(0) + _uleb(0) + _uleb(len(idxs)) + _uleb(0) # direct (static) methods
|
||||
prev = 0
|
||||
for i in idxs:
|
||||
blob += _uleb(i - prev) + _uleb(9) + _uleb(code_offs[i]) # public static
|
||||
prev = i
|
||||
class_data[c] = place(blob)
|
||||
out = bytearray(header)
|
||||
out[:8] = b"dex\n035\0"
|
||||
struct.pack_into("<10I", out, 56, len(strings), sid_off, len(types), tid_off, len(protos), pid_off, 0, 0,
|
||||
len(mids), mid_off)
|
||||
struct.pack_into("<2I", out, 96, len(classes), cdef_off)
|
||||
out += b"".join(struct.pack("<I", o) for o in str_offs)
|
||||
out += b"".join(struct.pack("<I", i) for i in types)
|
||||
out += b"".join(struct.pack("<3I", a, b, pl) for (a, b, _ps), pl in zip(protos, plists))
|
||||
out += b"".join(struct.pack("<HHI", *m) for m in mids)
|
||||
out += b"".join(struct.pack("<8I", t(c), 1, 0, 0, 0, 0, class_data[c], 0) for c in classes)
|
||||
out += data
|
||||
return bytes(out)
|
||||
|
||||
|
||||
# GRID Legends' OBBHandler, first instructions as in 1.0.0.41 (sget-object; if-nez; sget-object; const-string ...)
|
||||
MOUNT = [0x0062, 0x1C, 0x0039, 0x0E, 0x0062, 0x19, 0x011A, 0x47CD, 0x000E]
|
||||
MOUNTED_PATH = [0x0062, 0x1C, 0x0039, 0x09, 0x011A, 0x2D1A, 0x0011]
|
||||
|
||||
|
||||
def _dex() -> bytes:
|
||||
return build_dex([(H, "onMountOBBStateChanged", "VIL", 0, 0, None), # 0: native
|
||||
(H, "getOBBFullPath", "LL", 2, 1, [0x0000, 0x0011]), # 1
|
||||
(H, "mountOBB", "VL", 5, 1, MOUNT), # 2: name in v4
|
||||
(H, "getMountedOBBFullPath", "LL", 2, 1, MOUNTED_PATH), # 3: name in v1
|
||||
(H, "unmountOBB", "VL", 5, 1, [0x000E])])
|
||||
|
||||
|
||||
def _code(dex: Dex, name: str) -> list[int]:
|
||||
off = dex.code_items(H, name)[0]
|
||||
n = struct.unpack_from("<I", dex.data, off + 12)[0]
|
||||
return list(struct.unpack_from(f"<{n}H", dex.data, off + 16))
|
||||
|
||||
|
||||
def test_handler_rewrite_in_place():
|
||||
raw = _dex()
|
||||
assert handler_classes(raw + b"mountObb") == [H]
|
||||
dex = Dex(raw)
|
||||
assert dex.shorty(0) == "VIL" and dex.shorty(2) == "VL"
|
||||
assert sorted(patch_dex(dex)) == ["androidutils.OBBHandler.getMountedOBBFullPath",
|
||||
"androidutils.OBBHandler.mountOBB"]
|
||||
# const/4 v0, #1; invoke-static {v0, v4}, onMountOBBStateChanged; return-void; nop (to the boundary)
|
||||
assert _code(dex, "mountOBB") == [0x1012, 0x2071, 0, 0x0040, 0x000E, 0x0000] + MOUNT[6:]
|
||||
# invoke-static {v1}, getOBBFullPath; move-result-object v0; return-object v0; nop
|
||||
assert _code(dex, "getMountedOBBFullPath") == [0x1071, 1, 0x0001, 0x000C, 0x0011, 0x0000, 0x0011]
|
||||
assert _code(dex, "unmountOBB") == [0x000E]
|
||||
out = dex.finish()
|
||||
assert len(out) == len(raw) and patch_dex(Dex(out)) == []
|
||||
|
||||
|
||||
def test_invoke_static_encoding():
|
||||
assert Dex.invoke_static(0xCE, [0, 1]) == [0x2071, 0xCE, 0x0010] # GRID's own access$000
|
||||
with pytest.raises(ValueError):
|
||||
Dex.invoke_static(1, [16])
|
||||
|
||||
|
||||
def test_detect_triage_and_handlers():
|
||||
class A:
|
||||
extra = {"obb_mount": [H]}
|
||||
assert set(HANDLERS) == {H}
|
||||
assert ObbMountEmulation().detect(A()).recommended
|
||||
A.extra = {"obb_mount": ["other"]}
|
||||
assert ObbMountEmulation().applies(A()) and ObbMountEmulation().detect(A()) is None
|
||||
log = ("10-15 20:00:01.000 900 950 E AndroidRuntime: FATAL EXCEPTION: main\n"
|
||||
"10-15 20:00:01.000 900 950 E AndroidRuntime: java.lang.RuntimeException: Can't mount OBB "
|
||||
"DataSet0.obb (state=21)\n")
|
||||
r = triage(log, "EXITED", "com.codemasters.gridlegends")
|
||||
assert [f.id for f in r.findings] == ["obb-mount-failed"]
|
||||
assert r.findings[0].suggest == ["frame.obb_mount_emul"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------------------------------- agent side
|
||||
def load_agent(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
spec = importlib.util.spec_from_file_location("frameport_agent", AGENT)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
def _short(name: str) -> bytes:
|
||||
base, _, ext = name.upper().partition(".")
|
||||
return base[:8].ljust(8).encode() + ext[:3].ljust(3).encode()
|
||||
|
||||
|
||||
def _lfn_entries(name: str, short: bytes) -> list[bytes]:
|
||||
csum = 0
|
||||
for b in short:
|
||||
csum = (((csum & 1) << 7) + (csum >> 1) + b) & 0xFF
|
||||
chars = [name[i] for i in range(len(name))]
|
||||
pieces = [chars[i:i + 13] for i in range(0, len(chars), 13)]
|
||||
out = []
|
||||
for n, piece in enumerate(pieces, 1):
|
||||
u = "".join(piece).encode("utf-16-le")
|
||||
if len(piece) < 13:
|
||||
u += b"\0\0" + b"\xff\xff" * (12 - len(piece))
|
||||
e = bytearray(32)
|
||||
e[0] = n | (0x40 if n == len(pieces) else 0)
|
||||
e[1:11], e[11], e[13], e[14:26], e[28:32] = u[0:10], 0x0F, csum, u[10:22], u[22:26]
|
||||
out.append(bytes(e))
|
||||
return list(reversed(out))
|
||||
|
||||
|
||||
def make_fat32(path: Path, tree: dict, cluster=1024, footer_flags=None, fragment=False):
|
||||
"""A small FAT32 image (fat32lib layout: 32 reserved sectors, 2 FATs) with long names; tree = {name: bytes |
|
||||
dict}. fragment=True interleaves files' clusters (non-contiguous chains)."""
|
||||
bps, spc = 512, cluster // 512
|
||||
nclusters = 70000 # FAT32 needs >= 65525 clusters
|
||||
fat_sectors = (nclusters + 2) * 4 // bps + 1
|
||||
res, nfat = 32, 2
|
||||
data_off = (res + nfat * fat_sectors) * bps
|
||||
fat = [0x0FFFFFF8, 0x0FFFFFFF] + [0] * nclusters
|
||||
clusters: dict[int, bytes] = {}
|
||||
nxt = [3]
|
||||
|
||||
def alloc(blob: bytes) -> int:
|
||||
n = max(1, (len(blob) + cluster - 1) // cluster)
|
||||
step = 2 if fragment else 1
|
||||
cs = [nxt[0] + i * step for i in range(n)]
|
||||
nxt[0] = cs[-1] + 1
|
||||
for i, c in enumerate(cs):
|
||||
fat[c] = cs[i + 1] if i + 1 < n else 0x0FFFFFFF
|
||||
clusters[c] = blob[i * cluster:(i + 1) * cluster].ljust(cluster, b"\0")
|
||||
return cs[0]
|
||||
|
||||
def directory(entries: dict, first: int | None = None) -> int:
|
||||
raw = bytearray()
|
||||
for i, (name, content) in enumerate(entries.items()):
|
||||
short = _short(name)[:6] + b"~" + str(i % 10).encode() + _short(name)[8:]
|
||||
if isinstance(content, dict):
|
||||
cl = directory(content)
|
||||
attr, size = 0x10, 0
|
||||
else:
|
||||
cl = alloc(content) if content else 0
|
||||
attr, size = 0x20, len(content)
|
||||
raw += b"".join(_lfn_entries(name, short))
|
||||
e = bytearray(32)
|
||||
e[0:11], e[11] = short, attr
|
||||
struct.pack_into("<H", e, 26, cl & 0xFFFF)
|
||||
struct.pack_into("<H", e, 20, cl >> 16)
|
||||
struct.pack_into("<I", e, 28, size)
|
||||
raw += e
|
||||
raw += b"\xe5" + b"\0" * 31 # a deleted entry
|
||||
if first is not None:
|
||||
fat[first] = 0x0FFFFFFF
|
||||
clusters[first] = bytes(raw).ljust(cluster, b"\0")
|
||||
return first
|
||||
return alloc(bytes(raw) + b"\0" * 32)
|
||||
|
||||
directory(tree, first=2)
|
||||
total = data_off // bps + nclusters * spc
|
||||
bs = bytearray(512)
|
||||
bs[0:3], bs[3:11] = b"\xeb\x3c\x90", b"fat32lib"
|
||||
struct.pack_into("<HBHBHHBH", bs, 11, bps, spc, res, nfat, 0, 0, 0xF8, 0)
|
||||
struct.pack_into("<II", bs, 32, total, fat_sectors)
|
||||
struct.pack_into("<I", bs, 44, 2)
|
||||
bs[82:90], bs[510:512] = b"FAT32 ", b"\x55\xaa"
|
||||
fat_bytes = b"".join(struct.pack("<I", v) for v in fat).ljust(fat_sectors * bps, b"\0")
|
||||
with open(path, "wb") as f:
|
||||
f.write(bs)
|
||||
f.write(b"\0" * (res * bps - 512))
|
||||
f.write(fat_bytes * nfat)
|
||||
for c in sorted(clusters):
|
||||
f.seek(data_off + (c - 2) * cluster)
|
||||
f.write(clusters[c])
|
||||
f.truncate(total * bps)
|
||||
f.seek(0, 2)
|
||||
if footer_flags is not None:
|
||||
pkg = b"com.example.game"
|
||||
ft = struct.pack("<III", 1, 51, footer_flags) + b"\0" * 8 + struct.pack("<I", len(pkg)) + pkg
|
||||
f.write(ft + struct.pack("<II", len(ft), 0x01059983))
|
||||
|
||||
|
||||
TREE = {"index.txt": b"data/x\n", "data": {"Mixed_Case.bk2": b"B" * 3000, "empty.bin": b"",
|
||||
"deep": {"a.erp": bytes(range(256)) * 9}}}
|
||||
|
||||
|
||||
def _check(root: Path):
|
||||
assert (root / "index.txt").read_bytes() == b"data/x\n"
|
||||
assert (root / "data/Mixed_Case.bk2").read_bytes() == b"B" * 3000
|
||||
assert (root / "data/empty.bin").read_bytes() == b""
|
||||
assert (root / "data/deep/a.erp").read_bytes() == bytes(range(256)) * 9
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fragment", [False, True])
|
||||
def test_fat_reader_unpacks_long_names_and_chains(monkeypatch, tmp_path, fragment):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
img = tmp_path / "DataSet0.obb"
|
||||
make_fat32(img, TREE, footer_flags=0, fragment=fragment)
|
||||
assert a.is_obb_image(str(img)) and a.obb_footer(str(img))["package"] == "com.example.game"
|
||||
assert a.unpack_obb_image(str(img), str(tmp_path / "out")) == (4, 7 + 3000 + 2304)
|
||||
_check(tmp_path / "out")
|
||||
|
||||
|
||||
def test_encrypted_and_non_fat_images_are_refused(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
img = tmp_path / "x.obb"
|
||||
make_fat32(img, TREE, footer_flags=1)
|
||||
with pytest.raises(a.AgentError, match="encrypted"):
|
||||
a.unpack_obb_image(str(img), str(tmp_path / "out"))
|
||||
zipped = tmp_path / "main.1.com.x.obb"
|
||||
zipped.write_bytes(b"PK\3\4" + b"\0" * 600)
|
||||
assert not a.is_obb_image(str(zipped))
|
||||
|
||||
|
||||
def test_unsafe_names_are_refused(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
img = tmp_path / "x.obb"
|
||||
make_fat32(img, {"..\\evil": b"x"})
|
||||
with pytest.raises(a.AgentError):
|
||||
a.unpack_obb_image(str(img), str(tmp_path / "out"))
|
||||
assert not (tmp_path / "evil").exists()
|
||||
|
||||
|
||||
def test_unpack_command_and_manifest(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
base = tmp_path / "game"
|
||||
obb = base / "lepton-app" / "obb"
|
||||
obb.mkdir(parents=True)
|
||||
make_fat32(obb / "DataSet0.obb", TREE, footer_flags=0)
|
||||
(obb / "plain.obb").write_bytes(b"PK\3\4 not an image")
|
||||
size = (obb / "DataSet0.obb").stat().st_size
|
||||
monkeypatch.setattr(a, "deployment", lambda pkg: {"base": str(base), "appid": 1, "title": "G"})
|
||||
monkeypatch.setattr(a, "container_running", lambda appid: False)
|
||||
assert a.cmd_unpack_obb({"package": "com.example.game"}) == {"pending": ["DataSet0.obb"]}
|
||||
done = a.cmd_unpack_obb({"package": "com.example.game", "image": "DataSet0.obb"})
|
||||
assert done["files"] == 4
|
||||
assert (obb / "DataSet0.obb").is_dir()
|
||||
_check(obb / "DataSet0.obb")
|
||||
assert json.loads((obb / "DataSet0.obb" / a.OBB_MARKER).read_text())["size"] == size
|
||||
# the PC's "already on the Frame" check and finalize's data check see the image, not its files
|
||||
assert a.obb_manifest(str(obb)) == {"DataSet0.obb": size, "plain.obb": 17}
|
||||
assert a.cmd_unpack_obb({"package": "com.example.game"}) == {"pending": []}
|
||||
with pytest.raises(a.AgentError):
|
||||
a.cmd_unpack_obb({"package": "com.example.game", "image": "plain.obb"})
|
||||
|
||||
|
||||
def test_interrupted_unpack_is_finished_or_dropped(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
obb = tmp_path / "obb"
|
||||
done = obb / ("DataSet1.obb" + a.OBB_UNPACKING) # complete, image already removed: only the rename was missing
|
||||
done.mkdir(parents=True)
|
||||
(done / a.OBB_MARKER).write_text(json.dumps({"size": 5}))
|
||||
half = obb / ("DataSet2.obb" + a.OBB_UNPACKING) # no marker: dropped (the image is still there)
|
||||
half.mkdir()
|
||||
(half / "f").write_bytes(b"x")
|
||||
a.finish_obb_unpacks(str(obb))
|
||||
assert sorted(os.listdir(obb)) == ["DataSet1.obb"]
|
||||
assert a.obb_manifest(str(obb)) == {"DataSet1.obb": 5}
|
||||
|
||||
|
||||
def test_native_fix_only_touches_the_known_build():
|
||||
import hashlib
|
||||
|
||||
from frameport.analysis import elf
|
||||
from frameport.patches.frame.obb_mount import NATIVE_FIXES, NativeFix, native_fix
|
||||
|
||||
assert NATIVE_FIXES[0].lib == "libgrid.so"
|
||||
data = (Path(__file__).resolve().parents[1] / "artifacts" / "arm64-v8a" / "libframe_xrshim.so").read_bytes()
|
||||
va, off, _size = elf.load_segments(data)[0]
|
||||
start = va + 0x40
|
||||
word = data[off + 0x48:off + 0x4C]
|
||||
fix = NativeFix("x.so", start, 0x20, hashlib.sha256(data[off + 0x40:off + 0x60]).hexdigest(),
|
||||
((start + 8, word, bytes.fromhex("1f2003d5")),))
|
||||
out = native_fix(data, fix)
|
||||
assert out is not None and len(out) == len(data) and out[off + 0x48:off + 0x4C] == bytes.fromhex("1f2003d5")
|
||||
assert out[:off + 0x48] == data[:off + 0x48] and out[off + 0x4C:] == data[off + 0x4C:]
|
||||
assert native_fix(out, fix) is None # already fixed / another build: left alone
|
||||
assert native_fix(b"not an elf", fix) is None
|
||||
Reference in new issue
Block a user