Files
spoopyghosty0--frameport/tests/test_unreal_obb_check.py
T
spoopyghosty0 8f932dfd9b Unreal OBB delivery: recognise obfuscated DownloaderActivity predicates (#159 Medal of Honor)
Medal of Honor (com.Virtuos.R6DLM) keeps Epic's DownloaderActivity and log text but renames the delivery predicates
and OBBData, so expansionFilesDelivered wasn't found and frame.unreal_skip_obb_delivery was never offered. Detection
now also matches a boolean method in the package's DownloaderActivity, called from onCreate, that carries Epic's
"Checking for file : " log and calls the expansion-downloader helpers; the patch rewrites those too. ANALYSIS_VERSION
18 -> 19. Heuristics eval unchanged (10/66); no existing recipe affected.
2026-10-15 01:54:33 -04:00

344 lines
16 KiB
Python

"""frame.unreal_skip_obb_check (GitHub #159, Contractors): Unreal's DownloaderActivity CRC-checks the OBB when the
manifest says bVerifyOBBOnStartUp=true; its screens are invisible in Lepton, so the game seems to hang."""
from __future__ import annotations
import zipfile
from conftest import build_axml
from frameport.analysis.detect import analyze, unreal_verify_obb
from frameport.apk import axml
from frameport.apk.workspace import ApkWorkspace
from frameport.core.events import Reporter
from frameport.core.models import Analysis
from frameport.patches import base
from frameport.validate.triage import triage
PKG = "com.CaveManStudio.ContractorsVR"
VERIFY = "com.epicgames.ue4.GameActivity.bVerifyOBBOnStartUp"
def _manifest(verify=True, kind="bool"):
return build_axml([
("start", "manifest", [("package", "str", PKG)]),
("start", "application", []),
("start", "meta-data", [("name", "str", "com.epicgames.ue4.GameActivity.bHasOBBFiles"),
("value", "bool", True)]),
("end", "meta-data"),
("start", "meta-data", [("name", "str", VERIFY), ("value", kind, verify if kind == "bool" else str(verify))]),
("end", "meta-data"),
("start", "meta-data", [("name", "str", "com.epicgames.ue4.GameActivity.bShouldHideUI"),
("value", "bool", True)]),
("end", "meta-data"),
("end", "application"),
("end", "manifest"),
])
def _apk(tmp_path, manifest):
p = tmp_path / "game.apk"
with zipfile.ZipFile(p, "w") as z:
z.writestr("AndroidManifest.xml", manifest)
z.writestr("classes.dex", b"dex\n035\0")
return p
def _analysis(**extra):
return Analysis(package=PKG, version="3.9", label="Contractors", abis=["arm64-v8a"], engine="Unreal", xr="OpenXR",
graphics="GLES", direct_vrapi=False, libs=["libUE4.so", "libOVRPlugin.so"],
launcher_activity=None, has_info_category=False, meta_permissions=[], uses_glad_gl=False,
unity_msaa_levels=0, oculus_os_classes=False, is_overport_output=False, debuggable=False,
extra={"size": 1, **extra})
def test_set_meta_data_bool_changes_only_that_value():
m = _manifest()
out = axml.set_meta_data_bool(m, ".GameActivity.bVerifyOBBOnStartUp", False)
assert out is not None and len(out) == len(m)
assert sum(a != b for a, b in zip(m, out, strict=True)) == 4 # one 32-bit boolean
meta = axml.meta_data(out)
assert meta[VERIFY] is False
assert meta["com.epicgames.ue4.GameActivity.bHasOBBFiles"] is True # the others stay
assert meta["com.epicgames.ue4.GameActivity.bShouldHideUI"] is True
assert axml.set_meta_data_bool(out, ".GameActivity.bVerifyOBBOnStartUp", False) is None # already off
assert axml.set_meta_data_bool(out, ".GameActivity.bVerifyOBBOnStartUp", True) == m # round trip
def test_set_meta_data_bool_leaves_string_values():
"""Bundle.getBoolean reads a string "true" as false: nothing to change."""
assert axml.set_meta_data_bool(_manifest(kind="str"), ".GameActivity.bVerifyOBBOnStartUp", False) is None
def test_analysis_flag(tmp_path):
assert unreal_verify_obb({VERIFY: True})
assert unreal_verify_obb({"com.epicgames.unreal.GameActivity.bVerifyOBBOnStartUp": True}) # UE5's name
assert not unreal_verify_obb({VERIFY: False})
assert not unreal_verify_obb({VERIFY: "true"}) # not a boolean: Unreal reads false
assert not unreal_verify_obb({"com.epicgames.ue4.GameActivity.bHasOBBFiles": True})
assert analyze(_apk(tmp_path, _manifest())).extra["unreal_verify_obb"] is True
other = tmp_path / "off"
other.mkdir()
assert analyze(_apk(other, _manifest(verify=False))).extra["unreal_verify_obb"] is False
def test_patch_detects_applies_and_validates(tmp_path):
base.load_all()
patch = base.get("frame.unreal_skip_obb_check")
assert patch.detect(_analysis(unreal_verify_obb=True)).recommended
assert patch.detect(_analysis()) is None and not patch.applies(_analysis())
with ApkWorkspace(_apk(tmp_path, _manifest())) as ws:
ctx = base.ApkContext(ws, _analysis(unreal_verify_obb=True), {}, Reporter(), {patch.id: {}})
assert patch.validate(ctx)[0][1] is False
assert patch.apply(ctx)
assert patch.validate(ctx) == [("OBB check at start off", True, "bVerifyOBBOnStartUp=false")]
assert axml.meta_data(ws.read("AndroidManifest.xml"))[VERIFY] is False
assert not patch.apply(ctx) # nothing left to change
# the launch log of GitHub #159 (Lepton's logcat mirror; system_server lines have another pid than the game)
STUCK = f"""Boot complete!
10-09 18:02:11.120 1201 1230 I ActivityManager: Start proc 2345:{PKG}/u0a120 for pre-top-activity
10-09 18:02:12.004 2345 2345 D UE4 : [GameActivity] Target SDK is 32. This may cause issues if below 27.
10-09 18:02:12.410 1201 1802 I ActivityTaskManager: START u0 {{flg=0x10000 cmp={PKG}/.DownloaderActivity}}
10-09 18:02:12.900 1201 1240 I ActivityTaskManager: Displayed {PKG}/.DownloaderActivity: +394ms
"""
def test_triage_flags_the_stuck_downloader():
res = triage(STUCK, "RUNNING", PKG)
f = next(f for f in res.findings if f.id == "unreal-obb-check-stuck")
assert f.severity == "fatal" and f.suggest == ["frame.unreal_skip_obb_check", "frame.unreal_skip_obb_delivery"]
assert "DownloaderActivity" in f.evidence
assert res.verdict == "fail" # used to pass with only "Lepton container started"
def test_triage_ignores_a_downloader_that_returned():
later = STUCK + ("10-09 18:02:14.000 2345 2380 I FrameBridge: xrCreateInstance result=0\n"
"10-09 18:02:20.000 2345 2380 I FrameBridge: pacing: 72.0 fps\n")
assert not any(f.id == "unreal-obb-check-stuck" for f in triage(later, "RUNNING", PKG).findings)
crashed = STUCK + "10-09 18:02:14.000 2345 2380 F libc : Fatal signal 11 (SIGSEGV), code 1\n"
ids = [f.id for f in triage(crashed, "EXITED", PKG).findings]
assert "unreal-obb-check-stuck" not in ids and "native-crash" in ids
def test_triage_keeps_only_the_games_own_activity_lines():
other = STUCK.replace(f"{PKG}/.DownloaderActivity", "com.other.app/.DownloaderActivity")
assert not any(f.id == "unreal-obb-check-stuck" for f in triage(other, "RUNNING", PKG).findings)
def test_delivery_predicates_return_true_in_place():
import hashlib
import struct
import zlib
from test_vivox_audio_route import build_dex
from frameport.apk.dex import Dex
from frameport.patches.frame.unreal_obb_check import has_delivery_check, patch_dex
cls = f"L{PKG.replace('.', '/')}/DownloaderActivity;"
obb = f"L{PKG.replace('.', '/')}/OBBData;"
shim = "Lcom/epicgames/ue4/DownloadShim;"
methods = [(cls, "expansionFilesDelivered", "Z", [0x0013, 5, 0x000F]),
(obb, "expansionFilesDelivered", "Z", [0x0012, 0x000F]),
(shim, "expansionFilesDelivered", "Z", [0x0071, 0, 0, 0x000A, 0x000F]),
(cls, "other", "Z", [0x0012, 0x000F]),
("Lother/DownloaderActivity;", "expansionFilesDelivered", "Z", [0x0012, 0x000F]),
(cls, "expansionFilesDelivered", "I", [0x0012, 0x000F])]
raw = build_dex(methods)
dex = Dex(raw)
assert has_delivery_check(raw, PKG)
assert set(patch_dex(dex, PKG)) == {cls, obb, shim}
out = dex.finish()
assert len(out) == len(raw)
assert out[12:32] == hashlib.sha1(out[32:]).digest()
assert struct.unpack_from("<I", out, 8)[0] == zlib.adler32(out[12:]) & 0xFFFFFFFF
for idx, (_c, _n, _r, units) in enumerate(methods):
off = next(code for i, code in dex.class_methods(_c) if i == idx)
code = list(struct.unpack_from(f"<{len(units)}H", out, off + 16))
expected = ([0x1012, 0x000F] + ([0] if idx == 2 else []) + units[3 if idx == 2 else 2:]
if idx < 3 else units)
assert code == expected
assert out[off:off + 16] == raw[off:off + 16]
assert patch_dex(Dex(out), PKG) == []
def test_delivery_detection_and_multidex_apply_with_crc_already_off(tmp_path):
from test_vivox_audio_route import build_dex
from frameport.patches.frame.unreal_obb_check import UnrealSkipObbDelivery
patch = UnrealSkipObbDelivery()
path = _apk(tmp_path, _manifest(verify=False))
raw = build_dex([(f"L{PKG.replace('.', '/')}/DownloaderActivity;", "expansionFilesDelivered", "Z",
[0x0012, 0x000F])])
with zipfile.ZipFile(path, "a") as z:
z.writestr("classes2.dex", raw)
analysis = analyze(path)
assert analysis.extra["analysis_version"] >= 16
assert analysis.extra["unreal_obb_delivery"] is True
assert patch.applies(analysis) and patch.detect(analysis) is None
assert patch.experimental and not patch.default_on
assert not patch.applies(_analysis())
original = base.get("frame.unreal_skip_obb_check")
assert original.detect(analysis) is None and not original.applies(analysis)
assert original.revision == 1
with ApkWorkspace(path) as ws:
ctx = base.ApkContext(ws, analysis, {}, Reporter(), {patch.id: {}})
assert patch.validate(ctx)[-1][1] is False
assert patch.apply(ctx)
assert all(ok for _title, ok, _detail in patch.validate(ctx))
assert ws.read("AndroidManifest.xml") == _manifest(verify=False)
assert ws.read("classes.dex") == b"dex\n035\0"
assert len(ws.read("classes2.dex")) == len(raw)
assert "expansionFilesDelivered=true" in ctx.notes[-1]
assert not patch.apply(ctx)
def test_delivery_ignores_unrelated_referenced_and_unsupported_methods():
from test_vivox_audio_route import build_dex
from frameport.apk.dex import Dex
from frameport.patches.frame.unreal_obb_check import has_delivery_check, patch_dex
cls = f"L{PKG.replace('.', '/')}/DownloaderActivity;"
raw = build_dex([("Lother/DownloaderActivity;", "expansionFilesDelivered", "Z", [0x0012, 0x000F]),
(cls, "expansionFilesDelivered", "I", [0x0012, 0x000F]),
(cls, "expansionFilesDelivered", "Z", None)])
assert not has_delivery_check(raw, PKG)
dex = Dex(raw)
assert patch_dex(dex, PKG) == [] and bytes(dex.data) == raw
raw = build_dex([(cls, "expansionFilesDelivered", "Z", [0x000F])])
dex = Dex(raw)
assert patch_dex(dex, PKG) == [] # too short for const/4 + return
assert bytes(dex.data) == raw
def test_triage_does_not_blame_only_a_wrong_version_when_crc_is_off():
log = STUCK.replace("Boot complete!", "Boot complete!\nbVerifyOBBOnStartUp=false")
finding = next(f for f in triage(log, "RUNNING", PKG).findings if f.id == "unreal-obb-check-stuck")
assert "already disabled in the APK or by the skip patch" in finding.diagnosis
assert "external-storage path" in finding.diagnosis
def test_ue5_shim_and_zero_register_method():
import struct
from test_vivox_audio_route import build_dex
from frameport.apk.dex import Dex
from frameport.patches.frame.unreal_obb_check import has_delivery_check, patch_dex
cls = "Lcom/epicgames/unreal/DownloadShim;"
raw = build_dex([(cls, "expansionFilesDelivered", "Z", [0x0012, 0x000F])])
assert has_delivery_check(raw, PKG)
dex = Dex(raw)
off = dex.code_items(cls, "expansionFilesDelivered")[0]
struct.pack_into("<H", dex.data, off, 0)
before = bytes(dex.data)
assert patch_dex(dex, PKG) == [] and bytes(dex.data) == before
dex = Dex(raw)
assert patch_dex(dex, PKG) == [cls]
def test_crc_patch_preserves_head_output_with_delivery_dex(tmp_path):
from test_vivox_audio_route import build_dex
patch = base.get("frame.unreal_skip_obb_check")
manifest = _manifest()
raw = build_dex([(f"L{PKG.replace('.', '/')}/DownloaderActivity;", "expansionFilesDelivered", "Z",
[0x0012, 0x000F])])
path = _apk(tmp_path, manifest)
with zipfile.ZipFile(path, "a") as z:
z.writestr("classes2.dex", raw)
with ApkWorkspace(path) as ws:
before = {name: ws.read(name) for name in ws.names()}
ctx = base.ApkContext(ws, analyze(path), {}, Reporter(), {patch.id: {}})
assert patch.detect(ctx.analysis).recommended
assert patch.apply(ctx)
# HEAD writes only the manifest boolean; all other APK entries stay byte-identical.
expected = {**before, "AndroidManifest.xml": axml.set_meta_data_bool(manifest, VERIFY, False)}
assert {name: ws.read(name) for name in ws.names()} == expected
assert ctx.notes == ["bVerifyOBBOnStartUp=false"]
assert patch.validate(ctx) == [("OBB check at start off", True, "bVerifyOBBOnStartUp=false")]
def test_triage_offers_delivery_when_crc_patch_already_enabled(monkeypatch):
from dataclasses import asdict
from frameport import pipeline
base.load_all()
monkeypatch.setattr(pipeline.library, "game", lambda _pkg: {
"analysis": asdict(_analysis(unreal_verify_obb=True, unreal_obb_delivery=True)),
"recipe": {"patches": {"frame.unreal_skip_obb_check": {}}},
})
assert pipeline.useful_suggestions(PKG, triage(STUCK, "RUNNING", PKG).suggestions()) == [
"frame.unreal_skip_obb_delivery"]
def _obfuscated_delivery_dex(cls=None, *, log="Checking for file : ", called=True, helpers=True, ret="Z"):
from test_vivox_audio_route import build_dex
cls = cls or f"L{PKG.replace('.', '/')}/DownloaderActivity;"
helper = "Lcom/google/android/vending/expansion/downloader/d;"
# onCreate calls both obfuscated delivery predicates. The CRC predicate b() shares helper references,
# but has no delivery log and is not a delivery check.
code = [0x0012, 0x0000, 0x001A, 0, 0x1071, 0, 0, 0x1071, 1, 0, 0x000F]
return build_dex([
(helper if helpers else "Lother/Helper;", "a", "Ljava/lang/String;", None),
(helper if helpers else "Lother/Helper;", "b", "Z", None),
(cls, "a", ret, code), (cls, "d", ret, code),
(cls, "b", "Z", [0x1071, 1, 0, 0x000A, 0x000F]),
(cls, "onCreate", "V", [0x106E, 2, 0, 0x106E, 3, 0, 0x000E] if called else [0x000E]),
], strings_first=(log, "Checking for file : "))
def test_obfuscated_delivery_detection_apply_and_triage(tmp_path, monkeypatch):
from dataclasses import asdict
from frameport import pipeline
from frameport.apk.dex import Dex
from frameport.patches.frame.unreal_obb_check import UnrealSkipObbDelivery, delivery_methods, patch_dex
raw = _obfuscated_delivery_dex()
path = _apk(tmp_path, _manifest(verify=False))
with zipfile.ZipFile(path, "a") as z:
z.writestr("classes2.dex", raw)
analysis = analyze(path)
assert analysis.extra["unreal_obb_delivery"] is True
assert analysis.extra["unreal_verify_obb"] is False
patch = UnrealSkipObbDelivery()
assert patch.applies(analysis) and patch.detect(analysis) is None # opt-in after a stuck-downloader report
monkeypatch.setattr(pipeline.library, "game", lambda _pkg: {
"analysis": asdict(analysis), "recipe": {"patches": {}},
})
assert pipeline.useful_suggestions(PKG, triage(STUCK, "RUNNING", PKG).suggestions()) == [patch.id]
with ApkWorkspace(path) as ws:
ctx = base.ApkContext(ws, analysis, {}, Reporter(), {patch.id: {}})
assert len(patch.validate(ctx)) == 2 and not any(ok for _, ok, _ in patch.validate(ctx))
assert patch.apply(ctx)
out = ws.read("classes2.dex")
assert len(out) == len(raw)
assert len(patch.validate(ctx)) == 2 and all(ok for _, ok, _ in patch.validate(ctx))
assert ws.read("AndroidManifest.xml") == _manifest(verify=False)
assert ws.read("classes.dex") == b"dex\n035\0"
before, after = Dex(raw), Dex(out)
offsets = [code for _, code in delivery_methods(before, PKG)]
changed = {i for i, (a, b) in enumerate(zip(raw, out, strict=True)) if a != b}
assert changed <= set(range(8, 32)) | {o + 16 + i for o in offsets for i in range(4)}
assert after.code_items(f"L{PKG.replace('.', '/')}/DownloaderActivity;", "b") == before.code_items(
f"L{PKG.replace('.', '/')}/DownloaderActivity;", "b")
assert not patch.apply(ctx) and patch_dex(after, PKG) == []
def test_obfuscated_delivery_requires_activity_calls_log_and_helpers():
from frameport.patches.frame.unreal_obb_check import has_delivery_check
assert has_delivery_check(_obfuscated_delivery_dex(), PKG)
for raw in (_obfuscated_delivery_dex(cls="Lother/DownloaderActivity;"),
_obfuscated_delivery_dex(log="Checking something else"),
_obfuscated_delivery_dex(called=False), _obfuscated_delivery_dex(helpers=False),
_obfuscated_delivery_dex(ret="I")):
assert not has_delivery_check(raw, PKG)