Compare commits

...
Author SHA1 Message Date
spoopyghosty0 b1d099b14a Photon Voice: skip Android 12 audio routing (GitHub #362, #325)
Photon Voice's AudioInAEC.updateAudioDevice calls getAvailableCommunicationDevices/setCommunicationDevice with no version check; on Lepton's Android 11 the microphone thread dies with NoSuchMethodError (Table Troopers at the tutorial). New patch frame.photon_voice_audio_route (analysis photon_voice_api31, ANALYSIS_VERSION 20) makes it return at once, sharing vivox_audio_route's helper; Vivox's patch is unchanged. Triage vivox-api31 / photon-voice-api31 now look for the SDK in the stack; other libraries fall to audio-api31 (report).
2026-10-16 15:07:02 -04:00
12 changed files with 288 additions and 20 deletions

No files matched your search

+6
View File
@@ -949,6 +949,12 @@ Android 11. `frame.vivox_audio_route` (analysis `vivox_api31`, ANALYSIS_VERSION
once (`Dex.return_early`: return-void / `const/4 v0,0; return v0`; nopping the invoke would leave a move-result the
verifier rejects). Older Vivox (Eleven Table Tennis, BattleSisters) lacks that code and isn't matched. Verified
headless: Vivox initialises, 150 s at ~65-70 fps.
Photon Voice does the same (GitHub #362 Table Troopers, #325): `com.exitgames.photon.audioinaec.AudioInAEC.
updateAudioDevice` (void; only caller of getAvailableCommunicationDevices/setCommunicationDevice, reached from the mic
thread `AudioInAEC$1.run` via access$000; no SDK_INT check left) → sibling patch `frame.photon_voice_audio_route`
(analysis `photon_voice_api31`, ANALYSIS_VERSION 20; shares `vivox_audio_route.patch_class`; Vivox's id, title and
output unchanged, no revision bump). Triage: `vivox-api31` / `photon-voice-api31` now need the SDK's class in the
stack; any other `*CommunicationDevice*` NoSuchMethodError = `audio-api31` (report). Host-tested on the real dex only.
**Steam Input gamepad for 2D apps (GitHub #162, agent v72, 2026-10-10):** Lepton's Android only has the Wayland seat's `wayland_touch/keyboard/pointer`. Steam makes its virtual pad (uinput, `/devices/virtual/input`, 28de:11ff, "Microsoft X-Box 360 pad N", ACL for steamos) only while the Frame's controllers are on (controller.txt "Steam Controller reserving XInput slot 0"; gone when they sleep), also with no game running; steamos-manager's 28de:0000 keys device isn't a pad (no BTN_SOUTH). Lepton's container root (system_server too) = steamos (`keep-id:uid=0`), so a bind-mounted `/dev/input/eventN` opens. Opt-in `device.steam_gamepad` (vr_kind none; suggested for `android.hardware.gamepad`/`LEANBACK_LAUNCHER`, analysis `gamepad`, ANALYSIS_VERSION 9): deployment.json `steam_gamepad` → launcher `GAMEPAD_LINE` (after the codec line: exports FRAMEPORT_GAMEPAD + LEPTON_ENV_SDL_GAMECONTROLLER_ALLOW_STEAM_VIRTUAL_GAMEPAD, puts `agent/bin` first on PATH) → `PODMAN_WRAPPER` (written by ensure_host_fixes; non-`run` calls go straight on; `run` imports the agent's `podman_run_args`; hands on to the next Podman after its own PATH entry with itself removed from PATH, so it chains to the codec wrapper either way round) adds the pads + `Vendor_28de_Product_<pid>.kl` (Xbox 360 layout). Verified headless with a uinput stand-in pad (Stremio's container: EventHub `classes=0x80000141`, our key layout, KeyEvent BUTTON_A dispatched); with Steam's real pad and a game: not yet. Pads made after the start aren't seen until the next one.
**Multiview programs on flat framebuffers (GitHub #77, Doom3Quest, 2026-10-09, prototype):** Mesa enforces OVR_multiview's
"program num_views == draw framebuffer views" rule (`draw_validate.c`, the draw is dropped silently); Qualcomm doesn't.
+13 -1
View File
@@ -360,10 +360,22 @@ signatures:
suggest: [frame.slz_vulkan_hooks]
supersedes: [java-crash, native-crash]
- id: vivox-api31
pattern: 'NoSuchMethodError: No virtual method \w*CommunicationDevice\w*\(.*Landroid/media/AudioManager'
pattern: 'NoSuchMethodError: No virtual method \w*CommunicationDevice\w*\(.*Landroid/media/AudioManager[^\n]*(?:\n[^\n]*\sat [^\n]*){0,12}?\n[^\n]*\sat com\.vivox\.'
severity: fatal
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, audio-api31]
- id: photon-voice-api31
pattern: 'NoSuchMethodError: No virtual method \w*CommunicationDevice\w*\(.*Landroid/media/AudioManager[^\n]*(?:\n[^\n]*\sat [^\n]*){0,12}?\n[^\n]*\sat com\.exitgames\.photon\.'
severity: fatal
diagnosis: The game's Photon Voice microphone plugin calls Android 12 audio-routing methods (AudioManager communication devices) without checking the Android version; Lepton runs Android 11, so the app crashes when voice chat starts (for example Table Troopers at the tutorial). The patch makes Photon's audio-device pick return early (the microphone keeps the default route).
suggest: [frame.photon_voice_audio_route]
supersedes: [java-crash, audio-api31]
- id: audio-api31
pattern: 'NoSuchMethodError: No virtual method \w*CommunicationDevice\w*\(.*Landroid/media/AudioManager'
severity: fatal
diagnosis: Code in the game (often a 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. FramePort has fixes for Vivox and Photon Voice; this one comes from another library, so please report it with the log.
report: true
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/"
+16 -2
View File
@@ -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)
@@ -71,6 +71,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
| [Platform compat (ovrMessageType_ToString)](#frame-ovrplatformcompat) | `frame.ovrplatformcompat` | Steam Frame patches | default |
| [Stub missing Meta platform functions](#frame-ovrstubs) | `frame.ovrstubs` | Steam Frame patches | default |
| [Unpaired pointer-authentication checks](#frame-pac_hints) | `frame.pac_hints` | Steam Frame patches | 1 |
| [Photon Voice: skip Android 12 audio routing](#frame-photon_voice_audio_route) | `frame.photon_voice_audio_route` | Steam Frame patches | |
| [SDL apps: start without a clipboard service](#frame-sdl_clipboard) | `frame.sdl_clipboard` | Steam Frame patches | 5 |
| [Stress Level Zero: let Unity start Vulkan itself](#frame-slz_vulkan_hooks) | `frame.slz_vulkan_hooks` | Steam Frame patches | 1 |
| [Start straight in VR (skip the 2D launcher)](#frame-start_activity) | `frame.start_activity` | Steam Frame patches | |
@@ -831,6 +832,19 @@ Stops a crash on the Frame's processor in games whose code the Quest runs more l
> Some engines contain assembly (for example OpenSSL's) that checks a return-address signature it never made. The Quest's CPU ignores those checks, the Steam Frame's enforces them: the game crashes with SIGILL as soon as it uses that code, for example on its first network connection (Star Wars Pinball VR). In a library with unpaired checks, the signing and checking instructions become no-ops.
### <a id="frame-photon_voice_audio_route"></a>Photon Voice: skip Android 12 audio routing
Stops games with Photon Voice chat from crashing on the Frame when voice chat starts (an Android 12 audio call).
- `frame.photon_voice_audio_route` · also for 2D apps
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
- **Suggested (on):** Photon Voice calls Android 12 audio methods, which Lepton (Android 11) lacks, and crashes when voice chat starts.
- **Suggested by launch-test signatures:**
- `photon-voice-api31` (fatal): The game's Photon Voice microphone plugin calls Android 12 audio-routing methods (AudioManager communication devices) without checking the Android version; Lepton runs Android 11, so the app crashes when voice chat starts (for example Table Troopers at the tutorial).
- **Source:** [patches/frame/photon_voice_audio_route.py](../src/frameport/patches/frame/photon_voice_audio_route.py)
> Photon Voice's microphone plugin (for example Table Troopers) picks the audio device with Android 12 methods (AudioManager communication devices) without checking the Android version; Lepton runs Android 11, so the game crashes with a NoSuchMethodError when voice chat starts. That method returns at once instead (an in-place edit of classes.dex); the microphone keeps Android's default audio route.
### <a id="frame-sdl_clipboard"></a>SDL apps: start without a clipboard service
Lets SDL and LÖVE games start on the Frame (they'd crash looking for a clipboard).
+1
View File
@@ -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 crashes when voice chat starts (Table Troopers: at the tutorial's start): `java.lang.NoSuchMethodError: No virtual method getAvailableCommunicationDevices()… in class Landroid/media/AudioManager` at `com.exitgames.photon.audioinaec.AudioInAEC.updateAudioDevice` ← `AudioInAEC$1.run` (GitHub #362/#325); triage `photon-voice-api31` (the same error from another library: `audio-api31`, report it) | Photon Voice's Android microphone plugin picks the audio device with Android 12 methods (getAvailableCommunicationDevices / setCommunicationDevice) and has no version check (it logs "Start: SDK >= 31, using BroadcastReceiver" on every Android); Lepton is Android 11. `frame.vivox_audio_route` only edits Vivox's class, so it can't help here | `frame.photon_voice_audio_route` (suggested by analysis `photon_voice_api31`): AudioInAEC.updateAudioDevice (void, the only caller) returns at once (`Dex.return_early`, in place); the microphone thread goes on (communication mode, echo canceller, AudioRecord) on the default route. Checked against Table Troopers' classes.dex; not yet run on the device |
| 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`) |
+5
View File
@@ -509,6 +509,11 @@
"title": "Unpaired pointer-authentication checks",
"group": "Steam Frame patches"
},
"frame.photon_voice_audio_route": {
"anchor": "frame-photon_voice_audio_route",
"title": "Photon Voice: skip Android 12 audio routing",
"group": "Steam Frame patches"
},
"frame.sdl_clipboard": {
"anchor": "frame-sdl_clipboard",
"title": "SDL apps: start without a clipboard service",
+7 -1
View File
@@ -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: photon_voice_api31 (Photon Voice's AudioInAEC calls Android 12 AudioManager methods:
# frame.photon_voice_audio_route, GitHub #362/#325) (2026-10)
ANALYSIS_VERSION = 20
# Android versions by API level (for messages); the Frame's Lepton container runs Android 11 (API 30)
@@ -242,6 +244,9 @@ def analyze(path: Path, deep: bool = True, data_bytes: int | None = None) -> Ana
godot_clipboard = any(b"Lorg/godotengine/godot/Godot;" in d and GODOT_CLIPBOARD_CAST in d for d in dexes)
# Vivox voice chat calling Android 12 audio-routing methods: crashes in Lepton (Android 11)
vivox_api31 = any(b"Lcom/vivox/sdk/AudioChangeListener;" in d and b"CommunicationDevice" in d for d in dexes)
# Photon Voice's microphone plugin does the same (frame.photon_voice_audio_route, GitHub #362)
photon_voice_api31 = any(b"Lcom/exitgames/photon/audioinaec/AudioInAEC;" in d and b"CommunicationDevice" in d
for d in dexes)
# Epic Online Services' keychain (it can retry without end when it can't write: frame.eos_keychain_loop)
eos_keychain = any(b"Lcom/epicgames/mobile/eossdk/EOSSDK;" in d and b"resetKeychain" in d for d in dexes)
# bHaptics asks for an Android 12 permission; the prompt interrupts the game (frame.bhaptics_permission)
@@ -381,6 +386,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,
"photon_voice_api31": photon_voice_api31,
"eos_keychain": eos_keychain,
"bhaptics_permission": bhaptics_permission,
"media_codec": media_codec,
+3
View File
@@ -1133,6 +1133,8 @@
"PC VR ✓": "",
"Per-eye diagnostics (log)": "",
"Perfect pacing": "",
"Photon Voice's microphone plugin (for example Table Troopers) picks the audio device with Android 12 methods (AudioManager communication devices) without checking the Android version; Lepton runs Android 11, so the game crashes with a NoSuchMethodError when voice chat starts. That method returns at once instead (an in-place edit of classes.dex); the microphone keeps Android's default audio route.": "",
"Photon Voice: skip Android 12 audio routing": "",
"Pick several games and install them in one go. FramePort asks every question first, then works through them in the background.": "",
"Pick the field in the headset": "",
"Pick the program you'd double-click to play. Oculus builds usually work better than Steam builds.": "",
@@ -1526,6 +1528,7 @@
"Stops bHaptics from asking for a Bluetooth permission the Frame doesn't have (the game would freeze on its loading screen).": "",
"Stops Epic Online Services from retrying its login keychain forever (the game would never finish starting).": "",
"Stops games from hanging when their eye picture is a little too big (for example PowerWash Simulator). Leave it on.": "",
"Stops games with Photon Voice chat from crashing on the Frame when voice chat starts (an Android 12 audio call).": "",
"Stops Meta's audio library from reporting to Meta (only matters on PC emulators).": "",
"Stops Meta's old VR library from closing the game at start (for example Jurassic World Aftermath).": "",
"Stops some Unreal games from closing themselves right after starting.": "",
@@ -0,0 +1,46 @@
"""Photon Voice (Exit Games' Unity voice chat, e.g. Table Troopers, GitHub #362/#325) crashes in Lepton when voice
chat starts: its Java microphone plugin (com.exitgames.photon.audioinaec.AudioInAEC) picks the audio route in
updateAudioDevice with Android 12 AudioManager methods (getAvailableCommunicationDevices, setCommunicationDevice)
and has no Android version check left (it logs "Start: SDK >= 31, using BroadcastReceiver" on every Android), so on
Lepton's Android 11 the microphone thread dies with java.lang.NoSuchMethodError and takes the app with it. The
AudioInAEC methods that call them (updateAudioDevice: void) return at once instead, the same in-place classes.dex edit
as frame.vivox_audio_route. The microphone thread then goes on as on Android 12 (communication mode, echo canceller /
gain control / noise suppressor, AudioRecord) on Android's default route; only the device pick is skipped."""
from __future__ import annotations
from ..base import ApkContext, Patch, Suggestion, register
from .vivox_audio_route import patch_class, patch_dexes
AUDIO_IN = "Lcom/exitgames/photon/audioinaec/AudioInAEC;"
def patch_dex(dex) -> list[str]:
"""Methods of Photon Voice's AudioInAEC that now return at once; [] if nothing applies."""
return patch_class(dex, AUDIO_IN)
class PhotonVoiceAudioRoute(Patch):
id = "frame.photon_voice_audio_route"
title = "Photon Voice: skip Android 12 audio routing"
description = ("Photon Voice's microphone plugin (for example Table Troopers) picks the audio device with "
"Android 12 methods (AudioManager communication devices) without checking the Android version; "
"Lepton runs Android 11, so the game crashes with a NoSuchMethodError when voice chat starts. "
"That method returns at once instead (an in-place edit of classes.dex); the microphone keeps "
"Android's default audio route.")
order = 44
needs_vr = False
def applies(self, a):
return bool((a.extra or {}).get("photon_voice_api31"))
def detect(self, a):
if self.applies(a):
return Suggestion(True, "Photon Voice calls Android 12 audio methods, which Lepton (Android 11) lacks, "
"and crashes when voice chat starts.")
return None
def apply(self, ctx: ApkContext) -> bool:
return patch_dexes(ctx, AUDIO_IN, "Photon Voice AudioInAEC")
register(PhotonVoiceAudioRoute)
@@ -18,13 +18,14 @@ API31 = ("getAvailableCommunicationDevices", "setCommunicationDevice", "clearCom
"removeOnCommunicationDeviceChangedListener")
def patch_dex(dex: Dex) -> list[str]:
"""Methods of Vivox's AudioChangeListener that now return at once; [] if nothing applies."""
def patch_class(dex: Dex, cls: str) -> list[str]:
"""Methods of class `cls` that call one of the Android 12 communication-device methods and now return at once;
[] if nothing applies. Shared with frame.photon_voice_audio_route."""
targets = {i for name in API31 for i in dex.method_index(AUDIO_MANAGER, name)}
if not targets:
return []
done = []
for idx, code in dex.class_methods(LISTENER):
for idx, code in dex.class_methods(cls):
if not dex.invoked(code) & targets:
continue
if dex.return_early(code, dex.return_type(idx)):
@@ -32,6 +33,27 @@ def patch_dex(dex: Dex) -> list[str]:
return done
def patch_dexes(ctx: ApkContext, cls: str, label: str) -> bool:
"""patch_class on every classes*.dex that defines `cls`; notes what changed."""
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 cls.encode() not in data:
continue
dex = Dex(data)
done = patch_class(dex, cls)
if done:
ctx.ws.put(name, dex.finish())
ctx.notes.append(f"{name}: {label} returns at once in {', '.join(sorted(done))}")
changed = True
return changed
def patch_dex(dex: Dex) -> list[str]:
"""Methods of Vivox's AudioChangeListener that now return at once; [] if nothing applies."""
return patch_class(dex, LISTENER)
class VivoxAudioRoute(Patch):
id = "frame.vivox_audio_route"
title = "Vivox voice chat: skip Android 12 audio routing"
@@ -52,18 +74,7 @@ class VivoxAudioRoute(Patch):
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 LISTENER.encode() not in data:
continue
dex = Dex(data)
done = patch_dex(dex)
if done:
ctx.ws.put(name, dex.finish())
ctx.notes.append(f"{name}: Vivox AudioChangeListener returns at once in {', '.join(sorted(done))}")
changed = True
return changed
return patch_dexes(ctx, LISTENER, "Vivox AudioChangeListener")
register(VivoxAudioRoute)
+2
View File
@@ -84,6 +84,8 @@ SUMMARIES = {
"(they'd abort with \"cannot create thread specific key\"), for example Wander.",
"frame.vivox_audio_route": "Lets games with Vivox voice chat start on the Frame (they'd crash on an Android 12 "
"audio call).",
"frame.photon_voice_audio_route": "Stops games with Photon Voice chat from crashing on the Frame when voice chat "
"starts (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 "
"screen).",
"frame.unity_multipass": "Draws each eye separately. Try it when one eye shows a gray or broken picture.",
+160
View File
@@ -0,0 +1,160 @@
"""frame.photon_voice_audio_route (GitHub #362 Table Troopers, #325): Photon Voice's AudioInAEC.updateAudioDevice calls
Android 12 AudioManager methods with no version check; it returns at once (in-place dex edit, as for Vivox)."""
from __future__ import annotations
import hashlib
import os
import struct
import zipfile
import zlib
from pathlib import Path
import pytest
from test_vivox_audio_route import CHECK, build_dex
from frameport.analysis import detect
from frameport.apk.dex import Dex
from frameport.apk.workspace import ApkWorkspace
from frameport.core.events import Reporter
from frameport.patches import base
from frameport.patches.frame import vivox_audio_route
from frameport.patches.frame.photon_voice_audio_route import AUDIO_IN, PhotonVoiceAudioRoute, patch_dex
from frameport.validate.triage import triage
AM = "Landroid/media/AudioManager;"
RUNNER = "Lcom/exitgames/photon/audioinaec/AudioInAEC$1;"
# method indices: 0 getAvailableCommunicationDevices, 1 setCommunicationDevice, 2 access$000 (see _dex)
UPDATE = [0x1054, 0x0000, 0x106E, 0, 0x0000, 0x000C, 0x000E] # iget-object; invoke; move-result-object; return-void
ACCESS = [0x1070, 3, 0x0000, 0x000E] # invoke-direct {v0} updateAudioDevice; return-void
RUN = [0x1071, 2, 0x0000, 0x000E] # invoke-static {v0} access$000; return-void
START = [0x0012, 0x000F] # const/4 v0, 0; return v0
def _dex(vivox: bool = True) -> bytes:
return build_dex([(AM, "getAvailableCommunicationDevices", "Ljava/util/List;", None),
(AM, "setCommunicationDevice", "Z", None),
(AUDIO_IN, "access$000", "V", ACCESS),
(AUDIO_IN, "updateAudioDevice", "V", UPDATE),
(AUDIO_IN, "Start", "Z", START),
(RUNNER, "run", "V", RUN),
*[(vivox_audio_route.LISTENER, "checkAudioRouteAndApplyChanges", "V", CHECK)] * vivox])
def _code(dex: Dex, cls: str, name: str) -> list[int]:
off = dex.code_items(cls, 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_update_audio_device_returns_at_once():
raw = _dex()
dex = Dex(raw)
assert patch_dex(dex) == ["updateAudioDevice"]
assert _code(dex, AUDIO_IN, "updateAudioDevice") == [0x000E, 0] + UPDATE[2:] # iget-object (2 units) -> return-void
assert _code(dex, AUDIO_IN, "access$000") == ACCESS # callers stay as they are: the mic thread goes on
assert _code(dex, AUDIO_IN, "Start") == START
assert _code(dex, RUNNER, "run") == RUN
assert _code(dex, vivox_audio_route.LISTENER, "checkAudioRouteAndApplyChanges") == CHECK # Vivox is its own patch
out = dex.finish()
assert len(out) == len(raw) and patch_dex(Dex(out)) == []
def test_vivox_patch_leaves_photon_alone():
dex = Dex(_dex())
assert vivox_audio_route.patch_dex(dex) == ["checkAudioRouteAndApplyChanges"]
assert _code(dex, AUDIO_IN, "updateAudioDevice") == UPDATE
def test_apply_through_the_workspace(tmp_path, quest_manifest):
path = tmp_path / "game.apk"
with zipfile.ZipFile(path, "w") as z:
z.writestr("AndroidManifest.xml", quest_manifest)
z.writestr("classes.dex", _dex(vivox=False))
a = detect.analyze(path, deep=False)
assert a.extra["photon_voice_api31"] is True and a.extra["vivox_api31"] is False
assert a.extra["analysis_version"] == detect.ANALYSIS_VERSION >= 20
patch = base.get("frame.photon_voice_audio_route")
assert patch.applies(a) and patch.detect(a).recommended and not patch.needs_vr
assert not base.get("frame.vivox_audio_route").applies(a)
with ApkWorkspace(path) as ws:
ctx = base.ApkContext(ws, a, {}, Reporter(), {})
assert patch.apply(ctx)
assert ctx.notes == ["classes.dex: Photon Voice AudioInAEC returns at once in updateAudioDevice"]
assert _code(Dex(ws.read("classes.dex")), AUDIO_IN, "updateAudioDevice")[0] == 0x000E
assert not patch.apply(ctx) # once
def test_detect_needs_the_flag():
class A:
extra = {"photon_voice_api31": True}
assert PhotonVoiceAudioRoute().detect(A()).recommended
A.extra = {"vivox_api31": True}
assert PhotonVoiceAudioRoute().detect(A()) is None
# the crash of GitHub #362 (Table Troopers, the tutorial's start)
HEAD = ("10-16 16:22:38.975 1238 1413 E AndroidRuntime: FATAL EXCEPTION: Thread-23\n"
"10-16 16:22:38.975 1238 1413 E AndroidRuntime: Process: com.cosmorama.tabletroopers, PID: 1238\n"
"10-16 16:22:38.975 1238 1413 E AndroidRuntime: java.lang.NoSuchMethodError: No virtual method "
"getAvailableCommunicationDevices()Ljava/util/List; in class Landroid/media/AudioManager; or its super classes "
"(declaration of 'android.media.AudioManager' appears in /system/framework/framework.jar!classes2.dex)\n")
PHOTON = ("10-16 16:22:38.975 1238 1413 E AndroidRuntime: \tat com.exitgames.photon.audioinaec.AudioInAEC."
"updateAudioDevice(AudioInAEC.java:107)\n"
"10-16 16:22:38.975 1238 1413 E AndroidRuntime: \tat com.exitgames.photon.audioinaec.AudioInAEC."
"access$000(AudioInAEC.java:23)\n"
"10-16 16:22:38.975 1238 1413 E AndroidRuntime: \tat com.exitgames.photon.audioinaec.AudioInAEC$1."
"run(AudioInAEC.java:185)\n"
"10-16 16:22:38.975 1238 1413 E AndroidRuntime: \tat java.lang.Thread.run(Thread.java:923)\n")
VIVOX = ("10-16 16:22:38.975 1238 1413 E AndroidRuntime: \tat com.vivox.sdk.AudioChangeListener."
"checkAudioRouteAndApplyChanges(AudioChangeListener.java:80)\n")
OTHER = "10-16 16:22:38.975 1238 1413 E AndroidRuntime: \tat com.example.voice.Router.pick(Router.java:12)\n"
@pytest.mark.parametrize("stack, finding, suggest", [
(PHOTON, "photon-voice-api31", ["frame.photon_voice_audio_route"]),
(VIVOX, "vivox-api31", ["frame.vivox_audio_route"]),
(OTHER, "audio-api31", []),
])
def test_triage_names_the_sdk_in_the_stack(stack, finding, suggest):
r = triage(HEAD + stack, "EXITED", "com.cosmorama.tabletroopers")
assert [f.id for f in r.findings] == [finding] # java-crash and the generic one are superseded
assert r.findings[0].suggest == suggest
assert r.findings[0].report is (finding == "audio-api31")
def _table_troopers_apk() -> Path:
root = os.environ.get("FRAMEPORT_GAMES")
if not root:
pytest.skip("set FRAMEPORT_GAMES")
found = sorted(Path(root).glob("*/com.cosmorama.tabletroopers.apk")) or sorted(
Path(root).glob("**/com.cosmorama.tabletroopers.apk"))
if not found:
pytest.skip("no Table Troopers APK in FRAMEPORT_GAMES")
return found[0]
@pytest.mark.games
def test_real_table_troopers_dex():
with zipfile.ZipFile(_table_troopers_apk()) as z:
raw = z.read("classes.dex")
assert vivox_audio_route.patch_dex(Dex(raw)) == [] # no Vivox here: that patch was a no-op (#362)
dex = Dex(raw)
off = dex.code_items(AUDIO_IN, "updateAudioDevice")[0]
assert patch_dex(dex) == ["updateAudioDevice"]
out = dex.finish()
assert len(out) == len(raw)
# only the first instruction changed (const/16 -> return-void; nop), nothing else in the file
start = off + 16
assert raw[32:start] == out[32:start] and raw[start + 4:] == out[start + 4:]
assert struct.unpack_from("<2H", out, start) == (0x000E, 0x0000)
# header: SHA-1 signature and Adler-32 checksum match the new bytes
assert out[12:32] == hashlib.sha1(out[32:]).digest()
assert struct.unpack_from("<I", out, 8)[0] == zlib.adler32(out[12:]) & 0xFFFFFFFF
# the microphone thread and every other method of the plugin are unchanged
again, orig = Dex(out), Dex(raw)
for cls in (AUDIO_IN, RUNNER):
for idx, code in orig.class_methods(cls):
if orig.method(idx)[1] != "updateAudioDevice":
n = struct.unpack_from("<I", raw, code + 12)[0]
assert raw[code:code + 16 + 2 * n] == out[code:code + 16 + 2 * n]
assert patch_dex(again) == []
+3 -1
View File
@@ -157,7 +157,9 @@ def test_detect_and_triage():
log = ("10-08 21:00:01.000 900 950 E AndroidRuntime: FATAL EXCEPTION: main\n"
"10-08 21:00:01.000 900 950 E AndroidRuntime: java.lang.NoSuchMethodError: No virtual method "
"getAvailableCommunicationDevices()Ljava/util/List; in class Landroid/media/AudioManager; or its super "
"classes (declaration of 'android.media.AudioManager' appears in /system/framework/framework.jar)\n")
"classes (declaration of 'android.media.AudioManager' appears in /system/framework/framework.jar)\n"
"10-08 21:00:01.000 900 950 E AndroidRuntime: \tat com.vivox.sdk.AudioChangeListener."
"checkAudioRouteAndApplyChanges(AudioChangeListener.java:80)\n")
r = triage(log, "EXITED", "com.Incuvo.GreenHellVR")
assert [f.id for f in r.findings] == ["vivox-api31"]
assert r.findings[0].suggest == ["frame.vivox_audio_route"]