Files
spoopyghosty0--frameport/tests/test_quest_dump.py
T
spoopyghosty0 6419295a00 Merge PR #245 (Star Wars: Tales from the Galaxy's Edge: splash bypass, multiview depth, SideQuest layout)
# Conflicts:
#	docs/PATCHES.md
#	src/frameport/sources/quest_dump.py
#	tests/test_quest_dump.py
#	tests/test_triage.py
2026-10-12 21:48:22 -04:00

796 lines
39 KiB
Python

"""Finding Quest games and their data (OBB) folders on disk (sources/quest_dump.py)."""
import zipfile
from pathlib import Path
import pytest
from frameport.sources import quest_dump
IDS: dict[str, tuple[str, int]] = {} # APK file name -> (package, versionCode) for names that don't say it
@pytest.fixture(autouse=True)
def package_from_file_name(monkeypatch):
"""com.x.game.apk -> (com.x.game, None); com.x.game_5.apk -> (com.x.game, 5); else IDS."""
def ids(apk):
name = Path(apk).name
if name in IDS:
return IDS[name]
pkg, _, vc = Path(apk).stem.partition("_")
return pkg, int(vc) if vc.isdigit() else None
IDS.clear()
monkeypatch.setattr(quest_dump, "_apk_ids", ids)
def apk(path: Path) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(path, "w") as z:
z.writestr("AndroidManifest.xml", b"x")
return path
def obb(folder: Path, name: str = "main.1.com.x.game.obb") -> Path:
folder.mkdir(parents=True, exist_ok=True)
(folder / name).write_bytes(b"o")
return folder
def test_existing_layouts(tmp_path):
a = tmp_path / "Game A v1 -TAG"
apk(a / "com.x.game.apk")
obb(a / "com.x.game")
g = quest_dump.from_path(a)
assert (g.name, g.data_dir, g.origin) == ("Game A v1", a / "com.x.game", a)
b = tmp_path / "patched"
apk(b / "com.x.game.apk")
obb(b / "obb")
assert quest_dump.from_path(b).data_dir == b / "obb"
assert quest_dump.from_path(b / "com.x.game.apk").data_dir == b / "obb" # a single APK file
c = tmp_path / "no data"
apk(c / "com.x.game.apk")
assert quest_dump.from_path(c).data_dir is None
@pytest.mark.parametrize("data", ["obb/com.x.game", "OBB/com.x.game", "obbs/com.x.game", "Android/obb/com.x.game",
"backup/com.x.game"])
def test_package_folder_below_the_apk(tmp_path, data):
"""The data folder is the one holding the .obb files (its contents go to Android/obb/<package>/)."""
game = tmp_path / "Game"
apk(game / "com.x.game.apk")
obb(game / data)
found = quest_dump.scan(tmp_path)
assert len(found) == 1 and found[0].data_dir == game / data and found[0].name == "Game"
@pytest.mark.parametrize("apk_folder", ["apk", "APKs"])
def test_sidequest_style_backup_is_one_game(tmp_path, apk_folder):
game = tmp_path / "Backups" / "Some Game"
apk(game / apk_folder / "com.x.game.apk")
obb(game / "obb" / "com.x.game")
found = quest_dump.scan(tmp_path / "Backups")
assert len(found) == 1
g = found[0]
assert (g.name, g.origin, g.data_dir) == ("Some Game", game, game / "obb" / "com.x.game")
assert quest_dump.from_path(g.apk).data_dir == game / "obb" / "com.x.game" # the APK file alone too
def test_other_games_obb_is_never_taken(tmp_path):
"""A neighbouring folder with its own APK is another game (e.g. another version): its data stays its own."""
v1, v2 = tmp_path / "Game v1", tmp_path / "Game v2"
apk(v1 / "com.x.game.apk")
apk(v2 / "com.x.game.apk")
obb(v2 / "data" / "com.x.game")
found = {g.origin.name: g for g in quest_dump.scan(tmp_path)}
assert found["Game v1"].data_dir is None
assert found["Game v2"].data_dir == v2 / "data" / "com.x.game"
def test_package_folder_without_obb_files_is_ignored_when_searched(tmp_path):
game = tmp_path / "Game"
apk(game / "apk" / "com.x.game.apk")
(game / "saves" / "com.x.game").mkdir(parents=True)
(game / "saves" / "com.x.game" / "save.dat").write_bytes(b"s")
assert quest_dump.from_path(game / "apk").data_dir is None
@pytest.mark.parametrize("apk_folder", ["apk", "apks", "APK"])
@pytest.mark.parametrize("data_folder", ["obb", "OBB", "obbs", "obb/com.coatsink.alone"])
def test_sidequest_raw_assets_without_expansion_files(tmp_path, apk_folder, data_folder):
"""GitHub #239: Aftermath's SideQuest backup has raw FMOD banks, no .obb file or package subfolder."""
game = tmp_path / "com.coatsink.alone" / "2026-10-12T17-31-23-687Z"
a = apk(game / apk_folder / "com.coatsink.alone.apk")
data = game / data_folder
data.mkdir(parents=True)
(data / "Master.bank").write_bytes(b"bank")
(data / "Master.strings.bank").write_bytes(b"strings")
(game / "manifest.json").write_text("{}")
found = quest_dump.scan(tmp_path)
assert len(found) == 1
g = found[0]
assert (g.name, g.origin, g.apk, g.data_dir, g.data_files) == (
"com.coatsink.alone", game, a, data, None)
assert g.data_bytes() == 11
assert quest_dump.from_path(a).data_dir == data
def test_raw_assets_in_a_neighbouring_backup_are_not_taken(tmp_path):
game = tmp_path / "com.coatsink.alone"
a = apk(game / "backup1" / "apk" / "com.coatsink.alone.apk")
apk(game / "backup2" / "apk" / "com.coatsink.alone.apk")
data = game / "backup2" / "obb"
data.mkdir()
(data / "Master.bank").write_bytes(b"bank")
assert quest_dump.from_path(a).data_dir is None
@pytest.mark.parametrize("data_folder", ["obb", "obbs"])
def test_sibling_expansion_files_keep_package_and_version_selection(tmp_path, data_folder):
from frameport import pipeline
from frameport.core import library
from frameport.install.installer import local_data_manifest
a = apk(tmp_path / "apks" / "com.x.game_7.apk")
data = tmp_path / data_folder
for name in ("main.5.com.x.game.obb", "main.7.com.x.game.obb", "main.8.com.x.game.obb",
"patch.6.com.x.game.obb", "patch.8.com.x.game.obb", "main.7.com.y.other.obb"):
obb(data, name)
(data / "readme.txt").write_text("keep")
g = quest_dump.from_path(a)
assert g.data_dir == data
assert g.data_files == ["main.7.com.x.game.obb", "patch.6.com.x.game.obb"]
assert local_data_manifest(g.data_dir, g.data_files) == {
"main.7.com.x.game.obb": 1, "patch.6.com.x.game.obb": 1}
# The other game has not been added: deletion must protect its files through selection, not library sharing.
library.upsert_game("com.x.game", apk=str(a), data_dir=str(g.data_dir), data_files=g.data_files)
assert set(pipeline.local_game_files("com.x.game")) == {
a, data / "main.7.com.x.game.obb", data / "patch.6.com.x.game.obb"}
assert tmp_path.resolve() in data.resolve().parents
pipeline.delete_local_files("com.x.game")
assert not a.exists() and not (data / "main.7.com.x.game.obb").exists()
assert not (data / "patch.6.com.x.game.obb").exists()
assert {f.name for f in data.iterdir()} == {
"main.5.com.x.game.obb", "main.8.com.x.game.obb", "patch.8.com.x.game.obb",
"main.7.com.y.other.obb", "readme.txt"}
def test_sibling_expansion_files_of_another_package_are_ignored(tmp_path):
a = apk(tmp_path / "apk" / "com.x.game.apk")
obb(tmp_path / "obb", "main.1.com.y.other.obb")
assert quest_dump.from_path(a).data_dir is None
# ------------------------------------------------------------------ expansion files found by name (GitHub #85/#91)
def test_obbs_next_to_the_apk_are_sent_alone(tmp_path):
"""APK and OBBs in one folder: one game; only its own .obb files are its data (the APK, notes and other packages'
OBBs stay on the PC)."""
from frameport.install.installer import local_data_manifest
game = tmp_path / "Games" / "TRIANGLE"
apk(game / "com.x.game_7.apk")
obb(game, "main.7.com.x.game.obb")
obb(game, "patch.7.COM.X.GAME.obb") # Windows copies change the case
obb(game, "main.3.com.y.other.obb")
(game / "readme.txt").write_text("hi")
found = quest_dump.scan(tmp_path / "Games")
assert len(found) == 1
g = found[0]
assert (g.name, g.data_dir, g.data_files) == ("TRIANGLE", game, ["main.7.com.x.game.obb", "patch.7.COM.X.GAME.obb"])
assert g.data_bytes() == 2
assert local_data_manifest(g.data_dir, g.data_files) == {"main.7.com.x.game.obb": 1, "patch.7.COM.X.GAME.obb": 1}
assert quest_dump.from_path(g.apk).data_files == g.data_files # the APK file alone too
def test_obb_folder_next_to_an_apk_folder_inside_a_package_folder(tmp_path):
"""<package>/apk/x.apk + <package>/obb/main.N.<package>.obb: the obb folder isn't named after the package."""
root = tmp_path / "Quest"
apk(root / "com.x.game" / "apk" / "game.apk")
IDS["game.apk"] = ("com.x.game", 12)
obb(root / "com.x.game" / "obb", "main.12.com.x.game.obb")
found = quest_dump.scan(root)
assert len(found) == 1
g = found[0]
assert (g.origin, g.data_dir, g.data_files) == (root / "com.x.game", root / "com.x.game" / "obb", None)
@pytest.mark.parametrize("obb_at", ["obb", "obbs", "Android/obb/com.crytek.climb", "."])
def test_sidequest_versionless_obb(tmp_path, obb_at):
"""GitHub #238: CryEngine also opens main.<package>.obb, without a versionCode."""
backup = tmp_path / "com.crytek.climb" / "2026-10-12T16-44-24-860Z"
a = apk(backup / "apk" / "com.crytek.climb.apk")
IDS[a.name] = ("com.crytek.climb", 666)
obb(backup / obb_at, "main.com.crytek.climb.obb")
[g] = quest_dump.scan(tmp_path)
assert (g.apk, g.data_dir) == (a, backup / obb_at)
assert g.data_files == (["main.com.crytek.climb.obb"] if obb_at == "." else None)
assert quest_dump.from_path(a).data_dir == g.data_dir
def test_versionless_obbs_select_only_the_package_and_prefer_usable_versions(tmp_path):
apk(tmp_path / "com.x.game.apk")
for name in ("main.COM.X.GAME.obb", "patch.com.x.game.obb", "main.5.com.x.game.obb",
"main.8.com.x.game.obb", "main.com.y.other.obb"):
obb(tmp_path, name)
assert quest_dump.find_data(tmp_path, "com.x.game", 6)[1] == ["main.5.com.x.game.obb", "patch.com.x.game.obb"]
assert quest_dump.find_data(tmp_path, "com.x.game", 1)[1] == ["main.COM.X.GAME.obb", "patch.com.x.game.obb"]
assert quest_dump.find_data(tmp_path, "com.x.game", None)[1] == ["main.8.com.x.game.obb", "patch.com.x.game.obb"]
@pytest.mark.parametrize("apk_folder,obb_folder", [("apk", "obb"), ("APKs", "OBBs")])
def test_sidequest_backup_keeps_asset_files_and_language_packs(tmp_path, apk_folder, obb_folder):
from frameport import pipeline
from frameport.analysis import langpacks
from frameport.install.installer import local_data_manifest
backup = tmp_path / "com.x.game" / "2026-10-12T16-35-27-760Z"
a = apk(backup / apk_folder / "com.x.game_7.apk")
data = obb(backup / obb_folder, "main.7.com.x.game.obb")
(data / "Season1Taco-Android_ASTC.pak").write_bytes(b"content")
(data / "WwiseBanksCoreTaco-Android_ASTC.pak").write_bytes(b"audio")
(data / "de.lang").write_bytes(b"language")
for g in (quest_dump.from_path(a), *quest_dump.scan(tmp_path)):
assert g.data_dir == data and g.data_files is None
assert g.data_bytes() == 21
assert local_data_manifest(g.data_dir, g.data_files) == {
"main.7.com.x.game.obb": 1, "Season1Taco-Android_ASTC.pak": 7,
"WwiseBanksCoreTaco-Android_ASTC.pak": 5, "de.lang": 8,
}
assert langpacks.find_content_files(pipeline._data_folder(g)) == [
"Season1Taco-Android_ASTC.pak", "WwiseBanksCoreTaco-Android_ASTC.pak",
]
assert langpacks.find_tags(pipeline._data_folder(g)) == ["de"]
@pytest.mark.parametrize("other_folder", [".", "com.y.other", "nested/com.y.other"])
def test_shared_sibling_obb_folder_keeps_other_games_assets_out(tmp_path, other_folder):
from frameport.install.installer import local_data_manifest
a = apk(tmp_path / "apk" / "com.x.game_7.apk")
data = obb(tmp_path / "obb", "main.7.com.x.game.obb")
other = obb(data / other_folder, "main.3.com.y.other.obb")
(other / "other.pak").write_bytes(b"other game's content")
g = quest_dump.from_path(a)
assert g.data_dir == data and g.data_files == ["main.7.com.x.game.obb"]
assert local_data_manifest(g.data_dir, g.data_files) == {"main.7.com.x.game.obb": 1}
@pytest.mark.parametrize("nested_obb", ["main.6.com.x.game.obb", "main.7.com.x.game.obb"])
def test_sibling_obb_folder_does_not_include_nested_expansion_files(tmp_path, nested_obb):
a = apk(tmp_path / "apk" / "com.x.game_7.apk")
data = obb(tmp_path / "obb", "main.7.com.x.game.obb")
obb(data / "old", nested_obb)
assert quest_dump.from_path(a).data_files == ["main.7.com.x.game.obb"]
def test_sidequest_backup_keeps_nested_assets(tmp_path):
from frameport.install.installer import local_data_manifest
a = apk(tmp_path / "apk" / "com.x.game_7.apk")
data = obb(tmp_path / "obb", "main.7.com.x.game.obb")
(data / "content").mkdir()
(data / "content" / "extra.pak").write_bytes(b"content")
g = quest_dump.from_path(a)
assert g.data_files is None
assert local_data_manifest(g.data_dir, g.data_files) == {"main.7.com.x.game.obb": 1, "content/extra.pak": 7}
def test_app_update_recovers_assets_for_an_old_sidequest_entry(tmp_path):
from frameport.core import library
a = apk(tmp_path / "apk" / "com.x.game_7.apk")
data = obb(tmp_path / "obb", "main.7.com.x.game.obb")
(data / "Season1Taco-Android_ASTC.pak").write_bytes(b"content")
g = {"apk": str(a), "data_dir": str(data), "data_files": ["main.7.com.x.game.obb"], "data_bytes": 1,
"recipe": {"source": "user", "patches": {"adapter.scale": {"value": 0.8}}}}
analysis = {"package": "com.x.game", "extra": {"lang_packs": [], "asset_files": []}}
assert library._refresh_data_fields(g, analysis)
assert g["data_files"] is None and g["data_bytes"] == 8
assert analysis["extra"]["data_bytes"] == 8
assert analysis["extra"]["asset_files"] == ["Season1Taco-Android_ASTC.pak"]
assert g["recipe"] == {"source": "user", "patches": {"adapter.scale": {"value": 0.8}}}
assert not library._refresh_data_fields(g, analysis)
@pytest.mark.parametrize("other_folder", [".", "com.y.other"])
def test_app_update_does_not_expand_a_shared_or_manual_data_selection(tmp_path, other_folder):
from frameport.core import library
a = apk(tmp_path / "apk" / "com.x.game_7.apk")
data = obb(tmp_path / "obb", "main.7.com.x.game.obb")
obb(data / other_folder, "main.3.com.y.other.obb")
g = {"apk": str(a), "data_dir": str(data), "data_files": ["main.7.com.x.game.obb"]}
analysis = {"package": "com.x.game", "extra": {"lang_packs": [], "asset_files": []}}
assert not library._refresh_data_fields(g, analysis)
assert g["data_files"] == ["main.7.com.x.game.obb"]
other = obb(tmp_path / "manual", "main.7.com.x.game.obb")
g["data_dir"] = str(other)
assert not library._refresh_data_fields(g, analysis)
assert g["data_files"] == ["main.7.com.x.game.obb"]
@pytest.mark.parametrize("unavailable", ["apk", "data", "both"])
@pytest.mark.parametrize("recipe_source", ["user", "heuristics"])
def test_app_update_retries_asset_recovery_after_backup_returns(tmp_path, monkeypatch, unavailable, recipe_source):
from copy import deepcopy
from frameport import __version__
from frameport.core import library
from frameport.recommend import catalog
monkeypatch.setattr(library, "REFRESH_ON_UPDATE", True)
monkeypatch.setattr(catalog, "lookup", lambda _: None)
clock = [1_800_000_000.0]
monkeypatch.setattr(library.time, "time", lambda: clock[0])
monkeypatch.setattr(library, "_last_data_retry", 0.0)
a = tmp_path / "apk" / "com.x.game_7.apk"
data = tmp_path / "obb"
if unavailable == "data":
apk(a)
if unavailable == "apk":
obb(data, "main.7.com.x.game.obb")
recipe = {"package": "com.x.game", "source": recipe_source,
"patches": {"adapter.scale": {"value": 0.8}}}
g = {"apk": str(a), "data_dir": str(data), "data_files": ["main.7.com.x.game.obb"], "data_bytes": 1,
"recipe": recipe, "analysis": {"package": "com.x.game", "engine": "Unreal", "abis": ["arm64-v8a"],
"libs": ["libovrplatformloader.so"],
"extra": {"lang_packs": [], "asset_files": [], "asset_file_api": True}}}
stored = {"settings": {"recipes.app_version": "0.0.1"}, "games": {"com.x.game": g}}
assert library._follow_catalog(stored)
assert stored["settings"]["recipes.app_version"] == __version__
assert g["data_refresh_pending"] and g["data_files"] == ["main.7.com.x.game.obb"]
# Persist the pending state, as an update followed by another app start would.
library.save(stored)
stored = library.load()
g = stored["games"]["com.x.game"]
assert g["data_refresh_pending"]
recipe_before_retry = deepcopy(g["recipe"])
apk(a)
obb(data, "main.7.com.x.game.obb")
(data / "content.pak").write_bytes(b"content")
(data / "de.lang").write_bytes(b"language")
assert not library._follow_catalog(stored) # frequent library reads don't repeatedly probe the drive
clock[0] += 601
assert library._follow_catalog(stored)
assert g["data_files"] is None and g["data_bytes"] == 16
assert g["analysis"]["extra"]["asset_files"] == ["content.pak"]
assert g["analysis"]["extra"]["lang_packs"] == ["de"]
assert "data_refresh_pending" not in g
if recipe_source == "user":
assert g["recipe"] == recipe_before_retry
assert g["recipe"]["patches"]["adapter.scale"] == {"value": 0.8}
else:
assert "frame.asset_files" in g["recipe"]["patches"]
clock[0] += 601
assert not library._follow_catalog(stored) # completed recovery is not repeated
@pytest.mark.parametrize("pending", [False, True])
def test_asset_recovery_retries_a_directory_discovery_failure(tmp_path, monkeypatch, pending):
from frameport.core import library
a = apk(tmp_path / "apk" / "com.x.game_7.apk")
data = obb(tmp_path / "obb", "main.7.com.x.game.obb")
(data / "content.pak").write_bytes(b"content")
(data / "de.lang").write_bytes(b"language")
g = {"apk": str(a), "data_dir": str(data), "data_files": ["main.7.com.x.game.obb"]}
if pending:
g["data_refresh_pending"] = True
analysis = {"package": "com.x.game", "extra": {"lang_packs": [], "asset_files": []}}
scandir = quest_dump.os.scandir
def unreadable(path):
if Path(path) == data:
raise PermissionError("backup directory temporarily unreadable")
return scandir(path)
with monkeypatch.context() as patch:
patch.setattr(quest_dump.os, "scandir", unreadable)
assert library._refresh_data_fields(g, analysis) is (not pending)
assert g["data_refresh_pending"] and g["data_files"] == ["main.7.com.x.game.obb"]
assert analysis["extra"] == {"lang_packs": [], "asset_files": []}
assert not library._refresh_data_fields(g, analysis)
# Ordinary scans stay tolerant of unreadable backup folders.
assert quest_dump.find_data(a.parent, "com.x.game", 7) == (None, None)
assert library._refresh_data_fields(g, analysis)
assert g["data_files"] is None and "data_refresh_pending" not in g
assert g["data_bytes"] == 16
assert analysis["extra"]["asset_files"] == ["content.pak"]
assert analysis["extra"]["lang_packs"] == ["de"]
@pytest.mark.parametrize("shared", [False, True])
@pytest.mark.parametrize("pending", [False, True])
def test_asset_recovery_retries_an_unreadable_nested_folder(tmp_path, monkeypatch, shared, pending):
from frameport.core import library
a = apk(tmp_path / "apk" / "com.x.game_7.apk")
data = obb(tmp_path / "obb", "main.7.com.x.game.obb")
# Below the bounded OBB discovery search: the whole-folder check must still read this directory.
nested = data / "a" / "b" / "c"
nested.mkdir(parents=True)
(nested / "content.pak").write_bytes(b"content")
if shared:
obb(nested, "main.3.com.y.other.obb")
files = ["main.7.com.x.game.obb"]
g = {"apk": str(a), "data_dir": str(data), "data_files": files, "data_bytes": 1}
if pending:
g["data_refresh_pending"] = True
analysis = {"package": "com.x.game", "extra": {"lang_packs": [], "asset_files": []}}
scandir = quest_dump.os.scandir
def unreadable(path):
if Path(path) == nested:
raise PermissionError("nested backup directory temporarily unreadable")
return scandir(path)
with monkeypatch.context() as patch:
patch.setattr(quest_dump.os, "scandir", unreadable)
with pytest.raises(PermissionError):
quest_dump.find_data(a.parent, "com.x.game", 7, strict=True)
assert quest_dump.find_data(a.parent, "com.x.game", 7) == (data, files)
assert library._refresh_data_fields(g, analysis) is (not pending)
assert g["data_refresh_pending"] and g["data_files"] == files and g["data_bytes"] == 1
assert analysis["extra"] == {"lang_packs": [], "asset_files": []}
assert not library._refresh_data_fields(g, analysis)
assert library._refresh_data_fields(g, analysis)
assert "data_refresh_pending" not in g
assert g["data_files"] == (files if shared else None)
assert g["data_bytes"] == (1 if shared else 8)
assert analysis["extra"]["asset_files"] == ([] if shared else ["content.pak"])
def test_asset_recovery_retries_a_transient_read_failure(tmp_path, monkeypatch):
from frameport.core import library
a = apk(tmp_path / "apk" / "com.x.game_7.apk")
data = obb(tmp_path / "obb", "main.7.com.x.game.obb")
(data / "content.pak").write_bytes(b"content")
g = {"apk": str(a), "data_dir": str(data), "data_files": ["main.7.com.x.game.obb"]}
analysis = {"package": "com.x.game", "extra": {"lang_packs": [], "asset_files": []}}
def unreadable(_):
raise PermissionError("backup temporarily unreadable")
with monkeypatch.context() as patch:
patch.setattr(library, "data_manifest", unreadable)
assert library._refresh_data_fields(g, analysis)
assert g["data_refresh_pending"] and g["data_files"] == ["main.7.com.x.game.obb"]
assert not library._refresh_data_fields(g, analysis)
assert library._refresh_data_fields(g, analysis)
assert g["data_files"] is None and "data_refresh_pending" not in g
assert analysis["extra"]["asset_files"] == ["content.pak"]
@pytest.mark.parametrize("obb_at", ["obbs", "obb/2026-10-07T21-11-04.426Z", "Android/obb/com.x.game", "."])
def test_sidequest_backup_with_timestamped_apks(tmp_path, obb_at):
"""SideQuest backups name APKs <timestamp>_<versionCode>.apk; where the OBB sits varies."""
root = tmp_path / "backups" / "com.x.game"
a = apk(root / "apks" / "2026-10-07T21-11-04.426Z_3244131.apk")
IDS[a.name] = ("com.x.game", 3244131)
obb(root / obb_at, "main.3244131.com.x.game.obb")
[g] = quest_dump.scan(tmp_path / "backups")
assert (g.apk, g.data_dir) == (a, root / obb_at)
# next to the apks folder: only the file
assert g.data_files == (["main.3244131.com.x.game.obb"] if obb_at == "." else None)
def test_two_versions_side_by_side(tmp_path):
folder = tmp_path / "apks"
apk(folder / "com.x.game_5.apk")
apk(folder / "com.x.game_6.apk")
for name in ("main.5.com.x.game.obb", "main.6.com.x.game.obb", "patch.6.com.x.game.obb"):
obb(folder, name)
assert quest_dump.from_path(folder / "com.x.game_6.apk").data_files == ["main.6.com.x.game.obb",
"patch.6.com.x.game.obb"]
assert quest_dump.from_path(folder / "com.x.game_5.apk").data_files == ["main.5.com.x.game.obb"]
# a version without its own files: the newest older ones (expansion files keep their first versionCode)
assert quest_dump.find_data(folder, "com.x.game", 9)[1] == ["main.6.com.x.game.obb", "patch.6.com.x.game.obb"]
assert quest_dump.find_data(folder, "com.x.game", 1)[1] == ["main.6.com.x.game.obb", "patch.6.com.x.game.obb"]
assert quest_dump.find_data(folder, "com.x.game", None)[1] == ["main.6.com.x.game.obb", "patch.6.com.x.game.obb"]
@pytest.mark.parametrize("older_folder", ["old", "obb", "OBBs"])
def test_the_folder_with_the_apks_own_version_wins(tmp_path, older_folder):
game = tmp_path / "Game"
apk(game / "apk" / "com.x.game_2.apk")
old = obb(game / older_folder, "main.1.com.x.game.obb")
(old / "old.pak").write_bytes(b"old content")
obb(game / "new", "main.2.com.x.game.obb")
g = quest_dump.from_path(game / "apk")
assert (g.data_dir, g.data_files) == (game / "new", None)
assert quest_dump.from_path(g.apk).data_dir == game / "new"
def test_selected_sibling_obb_folder_keeps_assets_without_unselected_versions(tmp_path):
a = apk(tmp_path / "apk" / "com.x.game_2.apk")
obb(tmp_path / "old", "main.1.com.x.game.obb")
data = obb(tmp_path / "obb", "main.2.com.x.game.obb")
(data / "content.pak").write_bytes(b"content")
g = quest_dump.from_path(a)
assert (g.data_dir, g.data_files) == (data, None)
# Retaining the entire folder must not reinclude expansion files for a different version.
obb(data, "main.3.com.x.game.obb")
g = quest_dump.from_path(a)
assert (g.data_dir, g.data_files) == (data, ["main.2.com.x.game.obb"])
def test_app_update_does_not_expand_an_outdated_sibling_obb_folder(tmp_path):
from frameport.core import library
a = apk(tmp_path / "apk" / "com.x.game_2.apk")
data = obb(tmp_path / "obb", "main.1.com.x.game.obb")
(data / "old.pak").write_bytes(b"old content")
obb(tmp_path / "new", "main.2.com.x.game.obb")
g = {"apk": str(a), "data_dir": str(data), "data_files": ["main.1.com.x.game.obb"]}
analysis = {"package": "com.x.game", "extra": {"lang_packs": [], "asset_files": []}}
assert not library._refresh_data_fields(g, analysis)
assert (g["data_dir"], g["data_files"]) == (str(data), ["main.1.com.x.game.obb"])
@pytest.mark.parametrize("name", ["main.2.com.x.game.obb", "main.com.x.game.obb"])
def test_obbs_by_name_never_come_from_another_games_folder(tmp_path, name):
v1, v2 = tmp_path / "Game v1", tmp_path / "Game v2"
apk(v1 / "com.x.game_1.apk")
apk(v2 / "com.x.game_2.apk")
obb(v2, name)
found = {g.origin.name: g for g in quest_dump.scan(tmp_path)}
assert found["Game v1"].data_dir is None
assert (found["Game v2"].data_dir, found["Game v2"].data_files) == (v2, [name])
def test_deleting_local_files_takes_only_the_games_own_obbs(tmp_path):
"""Uninstall → "also delete the files on this PC": a folder holding several games' APKs and OBBs stays."""
from frameport import pipeline
from frameport.core import library
shared = tmp_path / "Downloads"
for pkg in ("com.x.game", "com.y.other"):
apk(shared / f"{pkg}.apk")
obb(shared, f"main.1.{pkg}.obb")
for pkg in ("com.x.game", "com.y.other"):
g = quest_dump.from_path(shared / f"{pkg}.apk")
library.upsert_game(pkg, apk=str(g.apk), data_dir=str(g.data_dir), data_files=g.data_files)
assert set(pipeline.local_game_files("com.x.game")) == {shared / "com.x.game.apk", shared / "main.1.com.x.game.obb"}
assert pipeline.source_of(library.game("com.x.game")).data_files == ["main.1.com.x.game.obb"]
# ------------------------------------------------------------------ a game that expects an OBB (GitHub #85)
def test_expects_obb_from_unreals_manifest_flag():
from frameport.analysis.detect import expects_obb
assert expects_obb({"com.epicgames.ue4.GameActivity.bHasOBBFiles": True})
assert expects_obb({"com.epicgames.unreal.GameActivity.bHasOBBFiles": "true"}) # UE5's name
assert not expects_obb({"com.epicgames.ue4.GameActivity.bHasOBBFiles": False})
assert not expects_obb({"com.epicgames.ue4.GameActivity.bVerifyOBBOnStartUp": True})
def test_analysis_reads_the_obb_flag(tmp_path):
from conftest import build_axml
from frameport.analysis.detect import analyze
manifest = build_axml([
("start", "manifest", [("package", "str", "com.x.game")]),
("start", "application", []),
("start", "meta-data", [("name", "str", "com.epicgames.ue4.GameActivity.bHasOBBFiles"),
("value", "bool", True)]),
("end", "meta-data"),
("end", "application"),
("end", "manifest"),
])
p = tmp_path / "game.apk"
with zipfile.ZipFile(p, "w") as z:
z.writestr("AndroidManifest.xml", manifest)
assert analyze(p).extra["expects_obb"] is True
# ------------------------------------------------------------------ Unity split-binary builds (GitHub #92)
def _unity_apk(path: Path, files: dict[str, bytes]) -> Path:
from conftest import build_axml
manifest = build_axml([("start", "manifest", [("package", "str", "com.x.game")]), ("end", "manifest")])
with zipfile.ZipFile(path, "w") as z:
z.writestr("AndroidManifest.xml", manifest)
z.writestr("lib/arm64-v8a/libunity.so", b"\x7fELF")
for name, data in files.items():
z.writestr(name, data)
return path
D = "assets/bin/Data/"
@pytest.mark.parametrize("files, split", [
({"lib/arm64-v8a/libOculusXRPlugin.so": b"", D + "level0": b""}, True), # XR plugin, UnitySubsystems in the OBB
({"lib/arm64-v8a/libUnityOpenXR.so": b"", D + "level0": b""}, True),
({"lib/arm64-v8a/libOculusXRPlugin.so": b"", D + "UnitySubsystems/OculusXRPlugin/UnitySubsystemsManifest.json":
b"{}", D + "level0": b""}, False), # a full XR-plugin build
({D + "level0": b""}, False), # built-in VR, one scene (The Room VR): no sign
])
def test_unity_split_build_from_xr_plugin_without_subsystems(tmp_path, files, split):
from frameport.analysis.detect import analyze
extra = analyze(_unity_apk(tmp_path / "g.apk", files), deep=False).extra
assert (extra["unity_split"], extra["expects_obb"]) == (split, split)
def test_unity_split_build_from_more_scenes_than_levels(tmp_path, monkeypatch):
from frameport.analysis import unity_split
monkeypatch.setattr(unity_split, "build_scene_count", lambda ggm: 3 if ggm == b"GGM" else None)
for levels, split in ((1, True), (3, False)):
files = {D + "globalgamemanagers": b"GGM", **{f"{D}level{i}": b"" for i in range(levels)},
D + "level0.resS": b"", D + "level0.split1": b""} # parts of one level count once
p = _unity_apk(tmp_path / f"loose{levels}.apk", files)
with zipfile.ZipFile(p) as z:
assert unity_split.split_build(z, z.namelist()) is split
assert unity_split.split_build(z, z.namelist(), deep=False) is False
# packed into data.unity3d: only the bundle's head and the blocks up to globalgamemanagers are read
for levels, split in ((["level0"], True), (["level0", "level1", "level2"], False)):
p = _unity_apk(tmp_path / f"packed{len(levels)}.apk", {D + "data.unity3d": _unityfs(
{"globalgamemanagers": b"GGM", **{n: b"x" * 10 for n in levels}})})
with zipfile.ZipFile(p) as z:
assert unity_split.split_build(z, z.namelist()) is split
def _unityfs(nodes: dict[str, bytes]) -> bytes:
"""An uncompressed UnityFS bundle (format 7) with one data block."""
import struct
data = b"".join(nodes.values())
info = b"\0" * 16 + struct.pack(">i", 1) + struct.pack(">IIH", len(data), len(data), 0)
info += struct.pack(">i", len(nodes))
off = 0
for name, body in nodes.items():
info += struct.pack(">qqI", off, len(body), 4) + name.encode() + b"\0"
off += len(body)
head = b"UnityFS\0" + struct.pack(">I", 7) + b"5.x.x\0" + b"2021.3.1f1\0"
head += struct.pack(">qIII", 0, len(info), len(info), 0)
head += b"\0" * (-len(head) % 16)
return head + info + data
def test_unityfs_reader_stops_after_the_node(tmp_path):
import io
from frameport.analysis.unity_split import bundle_node
names, ggm = bundle_node(io.BytesIO(_unityfs({"globalgamemanagers": b"GGM", "level0": b"L"})))
assert (names, ggm) == (["globalgamemanagers", "level0"], b"GGM")
assert bundle_node(io.BytesIO(b"UnityWeb\0..."))[1] is None
def _entry(**kw):
e = {"package": "com.x.game", "analysis": {"extra": {"expects_obb": True}}, "data_dir": None, "data_bytes": 0}
e.update(kw)
return e
def test_missing_obb_is_flagged_before_install_and_in_launch_tests():
from frameport import pipeline
from frameport.validate.triage import TriageResult, add_missing_obb
assert pipeline.missing_obb(_entry())
assert not pipeline.missing_obb(_entry(data_dir="/games/x/com.x.game", data_bytes=10))
assert not pipeline.missing_obb(_entry(data_dir="/games/x/com.x.game", data_bytes=None)) # older entries
assert not pipeline.missing_obb(_entry(analysis={"extra": {"expects_obb": False}}))
assert not pipeline.missing_obb(_entry(kind="rift"))
from test_patches import _analysis
analysis = _analysis(package="com.x.game", engine="Unreal", extra={"size": 1, "expects_obb": True}).to_dict()
[note] = pipeline.analysis_warnings(_entry(analysis=analysis))
assert "folder named com.x.game" in note and "hangs at start" in note
# the launch test: the game logs nothing about it, so the finding comes from the library
r = add_missing_obb(TriageResult("RUNNING", "Lepton started"))
assert [f.id for f in r.findings] == ["missing-obb"] and r.verdict == "fail"
running = TriageResult("RUNNING", "Submitting frames", fps=72.0) # an OBB from an earlier install is there
assert not add_missing_obb(running).findings and running.verdict == "pass"
@pytest.mark.parametrize("stray_pkg", ["com.Armature.VR4", "com.epicgames.ue4"])
def test_sidequest_desktop_backups_folder(tmp_path, stray_pkg):
"""SideQuest's "SideQuest Backups" folder, scanned whole: <package>/<timestamp>/apk/<package>.apk +
obb/(main|patch).N.<package>.obb + data/ (the app's private files) + icon.png + manifest.json. VR4's backup also
holds a second APK in its obb folder (Unreal's VR4-Android-Shipping-arm64.apk), which must not become the game
or a second game (reddit, 2026-10-11)."""
root = tmp_path / "SideQuest Backups"
cabin = root / "com.pixeltoys.cabin" / "2026-10-08T12-08-41-153Z"
apk(cabin / "apk" / "com.pixeltoys.cabin.apk")
IDS["com.pixeltoys.cabin.apk"] = ("com.pixeltoys.cabin", 11479)
obb(cabin / "obb", "main.11479.com.pixeltoys.cabin.obb")
for d in ("cloud", "il2cpp", "Unity"):
(cabin / "data" / d).mkdir(parents=True)
(cabin / "icon.png").write_bytes(b"p")
(cabin / "manifest.json").write_text("{}")
vr4 = root / "com.Armature.VR4" / "2026-10-07T02-10-19-171Z"
apk(vr4 / "apk" / "com.Armature.VR4.apk")
IDS["com.Armature.VR4.apk"] = ("com.Armature.VR4", 203)
obb(vr4 / "obb", "main.203.com.Armature.VR4.obb")
obb(vr4 / "obb", "patch.203.com.Armature.VR4.obb")
apk(vr4 / "obb" / "VR4-Android-Shipping-arm64.apk")
IDS["VR4-Android-Shipping-arm64.apk"] = (stray_pkg, 1)
dungeon = root / "de.erthu.ancientdungeonfull" / "2026-10-06T22-31-00-000Z"
apk(dungeon / "apk" / "de.erthu.ancientdungeonfull.apk") # no obb folder at all
found = {g.apk.name: g for g in quest_dump.scan(root)}
assert set(found) == {"com.pixeltoys.cabin.apk", "com.Armature.VR4.apk", "de.erthu.ancientdungeonfull.apk"}
assert found["com.pixeltoys.cabin.apk"].data_dir == cabin / "obb"
assert found["com.pixeltoys.cabin.apk"].name == "com.pixeltoys.cabin" # not the backup's time stamp
v = found["com.Armature.VR4.apk"]
assert v.data_dir == vr4 / "obb"
assert sorted(v.data_files or [p.name for p in (vr4 / "obb").glob("*.obb")]) == [
"main.203.com.Armature.VR4.obb", "patch.203.com.Armature.VR4.obb"]
assert found["de.erthu.ancientdungeonfull.apk"].data_dir is None
def test_axrb_download_folder(tmp_path):
"""AXRB's launcher downloads to <Downloads>/AXRB/<app id>/<binary id>/: base.apk + the OBBs by their store names
(+ other asset files); its own patched builds go to AXRB/patched/<package>/<name>-axrb.apk (reddit, 2026-10-11)."""
root = tmp_path / "AXRB"
d = root / "1234567890" / "987654321"
a = apk(d / "base.apk")
IDS["base.apk"] = ("com.Armature.VR4", 203)
obb(d, "main.203.com.Armature.VR4.obb")
obb(d, "patch.203.com.Armature.VR4.obb")
(d / "extra_asset.dat").write_bytes(b"a")
p = apk(root / "patched" / "com.Armature.VR4" / "base-axrb.apk")
IDS["base-axrb.apk"] = ("com.Armature.VR4", 203)
found = quest_dump.scan(root)
games = {g.apk: g for g in found}
# the OBBs and AXRB's other store files (DLC, assets) go to Android/obb/<package>; the APK stays
assert games[a].data_dir == d and games[a].data_files == ["extra_asset.dat", "main.203.com.Armature.VR4.obb",
"patch.203.com.Armature.VR4.obb"]
assert p not in games and len(games) == 1 # AXRB's PC-patched copy isn't a second game
assert games[a].name == "com.Armature.VR4" # not the binary id
def test_rescan_replaces_an_entry_made_from_axrbs_patched_copy(tmp_path, monkeypatch):
"""Older FramePort scanned AXRB/patched/<package>/*-axrb.apk (no OBBs) over the original download; a rescan of
new games only must still add the original for that package."""
from frameport import pipeline
from frameport.core import library
root = tmp_path / "AXRB"
d = root / "1" / "2"
apk(d / "base.apk")
IDS["base.apk"] = ("com.x.game", 5)
obb(d, "main.5.com.x.game.obb")
old = tmp_path / "AXRB" / "patched" / "com.x.game" / "base-axrb.apk"
games = [{"package": "com.x.game", "apk": str(old)}, {"package": "com.y.other", "apk": str(tmp_path / "y.apk")}]
monkeypatch.setattr(library, "games", lambda: games)
monkeypatch.setattr(quest_dump, "_package_of", lambda a: quest_dump._apk_ids(a)[0])
added = []
monkeypatch.setattr(pipeline, "add_game", lambda src, rep=None: added.append(src) or {"package": "com.x.game"})
monkeypatch.setattr(pipeline.rift_dump, "scan", lambda *a, **k: [])
pipeline.add_path(root, only_new=True)
assert [s.apk.name for s in added] == ["base.apk"] and added[0].data_files == ["main.5.com.x.game.obb"]
def test_install_looks_for_data_copied_in_after_the_apk_was_added(tmp_path, monkeypatch):
"""GitHub #156: the APK was added alone; the OBB was copied next to it later. Installing finds and sends it."""
from frameport import pipeline
from frameport.core import library
a = apk(tmp_path / "BnS" / "BladeAndSorcery.apk")
IDS[a.name] = ("com.Warpfrog.BladeAndSorcery", 260730005)
obb(tmp_path / "BnS", "main.260730005.com.Warpfrog.BladeAndSorcery.obb")
stored = {}
monkeypatch.setattr(library, "upsert_game", lambda pkg, **kw: stored.update(kw) or {"package": pkg, **kw})
out = pipeline._find_late_data({"package": "com.Warpfrog.BladeAndSorcery", "apk": str(a), "data_dir": None})
assert out["data_dir"] == str(tmp_path / "BnS") and stored["data_files"] == [
"main.260730005.com.Warpfrog.BladeAndSorcery.obb"]
assert pipeline._find_late_data({"package": "x", "apk": str(tmp_path / "gone.apk")})["apk"].endswith("gone.apk")
def test_install_recovers_versionless_obb_skipped_by_older_scan(tmp_path, monkeypatch):
"""An already-added Climb backup gets its missing data on Update on Frame, without needing a rescan."""
from frameport import pipeline
from frameport.core import library
backup = tmp_path / "com.crytek.climb" / "2026-10-12T16-44-24-860Z"
a = apk(backup / "apk" / "com.crytek.climb.apk")
obb(backup / "obb", "main.com.crytek.climb.obb")
saved = {}
monkeypatch.setattr(library, "upsert_game", lambda pkg, **kw: saved.update(kw) or {"package": pkg, **kw})
out = pipeline._find_late_data({"package": "com.crytek.climb", "apk": str(a), "data_dir": None})
assert out["data_dir"] == str(backup / "obb")
assert saved["data_files"] is None and saved["data_bytes"] == 1