mirror of
https://github.com/spoopyghosty0/frameport.git
synced 2026-10-06 12:00:40 +02:00
417 lines
18 KiB
Python
417 lines
18 KiB
Python
"""Language packs (native/langpack + patch frame.langpacks).
|
|
|
|
The C library is compiled for this host and driven through ctypes against a stand-in for OVRPort's platform loader
|
|
(tests/fixtures/src/fakeloader.c) that went through the real ELF patch (elf.hide_exports): lookups for the hidden
|
|
names no longer find the loader's functions, and the library still reaches them by reading the loader's .dynsym.
|
|
Needs a C compiler on Linux; the device side (bionic) is not covered here."""
|
|
import ctypes as C
|
|
import platform
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import time
|
|
import zipfile
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from frameport.analysis import elf, langpacks
|
|
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.patches.frame import langpack as patch_mod
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
SRC = ROOT / "native/langpack/langpack.c"
|
|
FAKE = Path(__file__).with_name("fixtures") / "src" / "fakeloader.c"
|
|
CC = shutil.which("cc") or shutil.which("gcc") or shutil.which("clang")
|
|
pytestmark = pytest.mark.skipif(not CC or platform.system() != "Linux", reason="needs a C compiler on Linux")
|
|
|
|
GETLIST, STATUSBYID, GETCURRENT, SETCURRENT = 0x4AFC6F74, 0x5D955D38, 0x1F90F0D5, 0x5B4FBBE0
|
|
VOIDP = C.c_void_p
|
|
|
|
|
|
def _analysis(**kw):
|
|
d = dict(package="com.example.questgame", version="1.0", label="Quest Game", abis=["arm64-v8a"], engine="Unity",
|
|
xr="OpenXR", graphics="Vulkan (declared in manifest)", direct_vrapi=False, libs=[], launcher_activity=None,
|
|
has_info_category=True, meta_permissions=[], uses_glad_gl=False, unity_msaa_levels=0,
|
|
oculus_os_classes=False, is_overport_output=True, debuggable=True, extra={"size": 1})
|
|
d.update(kw)
|
|
return Analysis(**d)
|
|
|
|
|
|
def _compile(src, out, *extra):
|
|
subprocess.run([CC, "-shared", "-fPIC", "-O1", "-Wall", "-Wextra", "-Werror", *extra, str(src), "-o", str(out),
|
|
"-ldl", "-lpthread"], check=True, capture_output=True)
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def built(tmp_path_factory):
|
|
d = tmp_path_factory.mktemp("langpack-build")
|
|
_compile(FAKE, d / "fakeloader.so")
|
|
_compile(SRC, d / "langpack.so", "-fvisibility=hidden")
|
|
_compile(SRC, d / "langpack_fast.so", "-fvisibility=hidden", "-DFP_LIST_TIMEOUT_MS=100")
|
|
original = (d / "fakeloader.so").read_bytes()
|
|
patched, hidden = elf.hide_exports(original, patch_mod.EXPORTS)
|
|
return {"lib": d / "langpack.so", "fast": d / "langpack_fast.so", "original": original, "patched": patched,
|
|
"hidden": hidden}
|
|
|
|
|
|
def _dlclose(lib):
|
|
libdl = C.CDLL(None)
|
|
libdl.dlclose.argtypes = [VOIDP]
|
|
libdl.dlclose(lib._handle)
|
|
|
|
|
|
class Api:
|
|
"""The library under test (`lp`) and the stand-in loader behind it (`loader`)."""
|
|
|
|
def __init__(self, lp, loader):
|
|
self.lp, self.loader = lp, loader
|
|
|
|
def f(self, name, res, *args, lib=None):
|
|
fn = getattr(lib or self.lp, name)
|
|
fn.restype, fn.argtypes = res, list(args)
|
|
return fn
|
|
|
|
def pop(self):
|
|
return self.f("ovr_PopMessage", VOIDP)()
|
|
|
|
def msg_type(self, m):
|
|
return self.f("ovr_Message_GetType", C.c_uint32, VOIDP)(m)
|
|
|
|
def req_id(self, m):
|
|
return self.f("ovr_Message_GetRequestID", C.c_uint64, VOIDP)(m)
|
|
|
|
def is_error(self, m):
|
|
return bool(self.f("ovr_Message_IsError", C.c_bool, VOIDP)(m))
|
|
|
|
def free(self, m):
|
|
self.f("ovr_FreeMessage", None, VOIDP)(m)
|
|
|
|
def details(self, d):
|
|
s = lambda name: self.f(name, C.c_char_p, VOIDP)(d) # noqa: E731
|
|
lang = self.f("ovr_AssetDetails_GetLanguage", VOIDP, VOIDP)(d)
|
|
out = {"id": self.f("ovr_AssetDetails_GetAssetId", C.c_uint64, VOIDP)(d),
|
|
"type": s("ovr_AssetDetails_GetAssetType"), "status": s("ovr_AssetDetails_GetDownloadStatus"),
|
|
"path": s("ovr_AssetDetails_GetFilepath")}
|
|
if lang:
|
|
ls = lambda name: self.f(name, C.c_char_p, VOIDP)(lang) # noqa: E731
|
|
out.update(tag=ls("ovr_LanguagePackInfo_GetTag"), en=ls("ovr_LanguagePackInfo_GetEnglishName"),
|
|
native=ls("ovr_LanguagePackInfo_GetNativeName"))
|
|
return out
|
|
|
|
def ask(self, name, *args):
|
|
"""Send a request, pop the answer, return (message type, error?, payload getter result)."""
|
|
argtypes = [C.c_char_p] if args and isinstance(args[0], bytes) else ([C.c_uint64] if args else [])
|
|
req = self.f(name, C.c_uint64, *argtypes)(*args)
|
|
m = self.pop()
|
|
assert m and self.req_id(m) == req
|
|
return m
|
|
|
|
|
|
def _api(built, tmp_path, monkeypatch, which):
|
|
monkeypatch.delenv("FRAMEPORT_LANGPACK", raising=False)
|
|
lib = tmp_path / "lib"
|
|
lib.mkdir()
|
|
(lib / "libovrplatformloader.so").write_bytes(built["patched"]) # the name the library looks for
|
|
shutil.copy(built[which], lib / "libfp_langpack.so")
|
|
loader = C.CDLL(str(lib / "libovrplatformloader.so"))
|
|
lp = C.CDLL(str(lib / "libfp_langpack.so"))
|
|
yield Api(lp, loader)
|
|
_dlclose(lp)
|
|
_dlclose(loader)
|
|
|
|
|
|
@pytest.fixture
|
|
def api(built, tmp_path, monkeypatch):
|
|
yield from _api(built, tmp_path, monkeypatch, "lib")
|
|
|
|
|
|
@pytest.fixture
|
|
def api_fast(built, tmp_path, monkeypatch): # same library with a 100 ms GetList timeout
|
|
yield from _api(built, tmp_path, monkeypatch, "fast")
|
|
|
|
|
|
@pytest.fixture
|
|
def packs(tmp_path, monkeypatch):
|
|
root = tmp_path / "obb"
|
|
(root / "sub").mkdir(parents=True)
|
|
for rel in ("de.lang", "en-us.lang", "sub/fr.lang", "notes.txt", "x y.lang"):
|
|
(root / rel).write_bytes(b"data")
|
|
monkeypatch.setenv("FRAMEPORT_LANGPACK_DIRS", str(root))
|
|
return root
|
|
|
|
|
|
def test_exports_match_the_c_source():
|
|
text = SRC.read_text()
|
|
defined = set(re.findall(r"^EXPORT\s+[^;{(]*?\b(ovr_\w+)\s*\(", text, re.M))
|
|
assert defined == set(patch_mod.EXPORTS)
|
|
assert len(patch_mod.EXPORTS) == len(defined)
|
|
|
|
|
|
def test_hide_exports_keeps_symbols_but_hides_them_from_lookup(built, tmp_path):
|
|
assert set(built["hidden"]) >= {"ovr_PopMessage", "ovr_LanguagePack_GetCurrent", "ovr_AssetFile_GetList"}
|
|
assert "fake_freed" not in built["hidden"]
|
|
d = tmp_path / "orig"
|
|
d.mkdir()
|
|
(d / "a.so").write_bytes(built["original"])
|
|
(d / "b.so").write_bytes(built["patched"])
|
|
orig, patched = C.CDLL(str(d / "a.so")), C.CDLL(str(d / "b.so"))
|
|
try:
|
|
assert orig.ovr_PopMessage and patched.fake_freed # visible before, others untouched
|
|
with pytest.raises(AttributeError):
|
|
patched.ovr_PopMessage # noqa: B018 hidden: a lookup goes on to the next library
|
|
# the entries are still defined (the library reads their addresses from .dynsym)
|
|
assert "ovr_PopMessage" in elf.dyn_symbols(built["patched"], True)
|
|
assert elf.hide_exports(built["patched"], patch_mod.EXPORTS)[1] == [] # idempotent
|
|
finally:
|
|
_dlclose(orig)
|
|
_dlclose(patched)
|
|
|
|
|
|
def test_get_current_without_a_choice_is_an_error_when_several_packs(api, packs):
|
|
m = api.ask("ovr_LanguagePack_GetCurrent")
|
|
assert api.msg_type(m) == GETCURRENT and api.is_error(m)
|
|
err = api.f("ovr_Message_GetError", VOIDP, VOIDP)(m)
|
|
assert api.f("ovr_Error_GetCode", C.c_int, VOIDP)(err) == 404
|
|
assert b"no language pack" in api.f("ovr_Error_GetMessage", C.c_char_p, VOIDP)(err)
|
|
assert not api.f("ovr_Message_GetAssetDetails", VOIDP, VOIDP)(m)
|
|
api.free(m)
|
|
|
|
|
|
def test_get_current_by_environment_and_single_pack(api, packs, monkeypatch, tmp_path):
|
|
monkeypatch.setenv("FRAMEPORT_LANGPACK", "DE")
|
|
m = api.ask("ovr_LanguagePack_GetCurrent")
|
|
assert not api.is_error(m)
|
|
d = api.details(api.f("ovr_Message_GetAssetDetails", VOIDP, VOIDP)(m))
|
|
assert d["type"] == b"language_pack" and d["status"] == b"installed"
|
|
assert d["path"] == str(packs / "de.lang").encode()
|
|
assert (d["tag"], d["en"], d["native"]) == (b"de", b"German", b"Deutsch")
|
|
assert d["id"] >> 48 == 0x4650
|
|
api.free(m)
|
|
# exactly one pack and nothing chosen: that one
|
|
monkeypatch.delenv("FRAMEPORT_LANGPACK")
|
|
only = tmp_path / "only"
|
|
only.mkdir()
|
|
(only / "de.lang").write_bytes(b"x")
|
|
monkeypatch.setenv("FRAMEPORT_LANGPACK_DIRS", str(only))
|
|
m = api.ask("ovr_LanguagePack_GetCurrent")
|
|
assert api.details(api.f("ovr_Message_GetAssetDetails", VOIDP, VOIDP)(m))["tag"] == b"de"
|
|
api.free(m)
|
|
|
|
|
|
def test_set_current_then_status_then_get_current(api, packs):
|
|
m = api.ask("ovr_LanguagePack_SetCurrent", b"de-DE") # the base language matches
|
|
assert api.msg_type(m) == SETCURRENT and not api.is_error(m)
|
|
res = api.f("ovr_Message_GetAssetFileDownloadResult", VOIDP, VOIDP)(m)
|
|
asset = api.f("ovr_AssetFileDownloadResult_GetAssetId", C.c_uint64, VOIDP)(res)
|
|
path = api.f("ovr_AssetFileDownloadResult_GetFilepath", C.c_char_p, VOIDP)(res)
|
|
assert asset and path == str(packs / "de.lang").encode()
|
|
api.free(m)
|
|
|
|
m = api.ask("ovr_AssetFile_StatusById", asset) # the confirm step of the documented flow
|
|
assert api.msg_type(m) == STATUSBYID and not api.is_error(m)
|
|
d = api.details(api.f("ovr_Message_GetAssetDetails", VOIDP, VOIDP)(m))
|
|
assert (d["status"], d["path"], d["id"]) == (b"installed", path, asset)
|
|
api.free(m)
|
|
|
|
m = api.ask("ovr_LanguagePack_GetCurrent") # the choice sticks
|
|
assert api.details(api.f("ovr_Message_GetAssetDetails", VOIDP, VOIDP)(m))["tag"] == b"de"
|
|
api.free(m)
|
|
|
|
m = api.ask("ovr_AssetFile_StatusById", asset + 1) # ours by prefix, but unknown
|
|
assert api.is_error(m)
|
|
api.free(m)
|
|
|
|
|
|
def test_set_current_unknown_pack_is_an_error(api, packs):
|
|
for tag in (b"xx", b""):
|
|
m = api.ask("ovr_LanguagePack_SetCurrent", tag)
|
|
assert api.is_error(m) and not api.f("ovr_Message_GetAssetFileDownloadResult", VOIDP, VOIDP)(m)
|
|
api.free(m)
|
|
|
|
|
|
def test_asset_list_adds_our_packs_to_the_loaders_list(api, packs):
|
|
req = api.f("ovr_AssetFile_GetList", C.c_uint64)()
|
|
assert req == 100 # the loader's own request id
|
|
m = api.pop()
|
|
assert api.msg_type(m) == GETLIST and api.req_id(m) == req and not api.is_error(m)
|
|
arr = api.f("ovr_Message_GetAssetDetailsArray", VOIDP, VOIDP)(m)
|
|
size = api.f("ovr_AssetDetailsArray_GetSize", C.c_size_t, VOIDP)(arr)
|
|
items = [api.details(api.f("ovr_AssetDetailsArray_GetElement", VOIDP, VOIDP, C.c_size_t)(arr, i))
|
|
for i in range(size)]
|
|
assert size == 4
|
|
ours = [i for i in items if i["type"] == b"language_pack"]
|
|
assert {i["tag"] for i in ours} == {b"de", b"en-us", b"fr"} and {i["status"] for i in ours} == {b"installed"}
|
|
store = [i for i in items if i["type"] == b"store"] # the loader's own entry, served by the loader's accessors
|
|
assert store == [{"id": 7, "type": b"store", "status": b"available", "path": b"/store/a"}]
|
|
assert api.f("ovr_AssetDetailsArray_GetElement", VOIDP, VOIDP, C.c_size_t)(arr, 4) is None
|
|
before = api.loader.fake_freed()
|
|
api.free(m) # frees the loader's message behind ours too
|
|
assert api.loader.fake_freed() == before + 1
|
|
|
|
|
|
def _our_metadata(api):
|
|
api.f("ovr_AssetFile_GetList", C.c_uint64)()
|
|
m = api.pop()
|
|
arr = api.f("ovr_Message_GetAssetDetailsArray", VOIDP, VOIDP)(m)
|
|
get = api.f("ovr_AssetDetailsArray_GetElement", VOIDP, VOIDP, C.c_size_t)
|
|
out = {api.f("ovr_AssetDetails_GetMetadata", C.c_char_p, VOIDP)(get(arr, i))
|
|
for i in range(api.f("ovr_AssetDetailsArray_GetSize", C.c_size_t, VOIDP)(arr))
|
|
if api.f("ovr_AssetDetails_GetAssetType", C.c_char_p, VOIDP)(get(arr, i)) == b"language_pack"}
|
|
api.free(m)
|
|
return out
|
|
|
|
|
|
def test_metadata_is_empty_without_the_games_version(api, packs, monkeypatch):
|
|
monkeypatch.delenv("FRAMEPORT_LANGPACK_META", raising=False)
|
|
assert _our_metadata(api) == {b""}
|
|
|
|
|
|
def test_metadata_from_the_environment(api, packs, monkeypatch):
|
|
monkeypatch.setenv("FRAMEPORT_LANGPACK_META", "2.0.1.77.Quest")
|
|
assert _our_metadata(api) == {b"2.0.1.77.Quest"}
|
|
|
|
|
|
def test_patch_writes_the_games_version_as_the_metadata(built, tmp_path, monkeypatch):
|
|
monkeypatch.delenv("FRAMEPORT_LANGPACK_META", raising=False)
|
|
lib = patch_mod.with_metadata(built["lib"].read_bytes(), "1.0.40.356975.Quest")
|
|
assert len(lib) == built["lib"].stat().st_size and b"1.0.40.356975.Quest" in lib
|
|
assert patch_mod.with_metadata(lib, "") == lib and patch_mod.with_metadata(b"no marker", "1") == b"no marker"
|
|
assert patch_mod.with_metadata(lib, "x" * 200) == lib # too long for the slot: left as it was
|
|
d = tmp_path / "meta"
|
|
d.mkdir()
|
|
(d / "libovrplatformloader.so").write_bytes(built["patched"])
|
|
(d / "libfp_langpack.so").write_bytes(lib)
|
|
root = tmp_path / "obb"
|
|
root.mkdir()
|
|
(root / "de.lang").write_bytes(b"1")
|
|
monkeypatch.setenv("FRAMEPORT_LANGPACK_DIRS", str(root))
|
|
loader, lp = C.CDLL(str(d / "libovrplatformloader.so")), C.CDLL(str(d / "libfp_langpack.so"))
|
|
try:
|
|
assert _our_metadata(Api(lp, loader)) == {b"1.0.40.356975.Quest"}
|
|
finally:
|
|
_dlclose(lp)
|
|
_dlclose(loader)
|
|
|
|
|
|
def _tags(api):
|
|
api.f("ovr_AssetFile_GetList", C.c_uint64)()
|
|
m = api.pop()
|
|
arr = api.f("ovr_Message_GetAssetDetailsArray", VOIDP, VOIDP)(m)
|
|
get = api.f("ovr_AssetDetailsArray_GetElement", VOIDP, VOIDP, C.c_size_t)
|
|
size = api.f("ovr_AssetDetailsArray_GetSize", C.c_size_t, VOIDP)(arr)
|
|
out = {api.details(get(arr, i)).get("tag") for i in range(size)}
|
|
api.free(m)
|
|
out.discard(None)
|
|
return out
|
|
|
|
|
|
def test_packs_can_be_left_out_by_environment_or_file(api, packs, monkeypatch):
|
|
assert _tags(api) == {b"de", b"en-us", b"fr"}
|
|
monkeypatch.setenv("FRAMEPORT_LANGPACK_SKIP", "EN-US, x")
|
|
assert _tags(api) == {b"de", b"fr"}
|
|
monkeypatch.delenv("FRAMEPORT_LANGPACK_SKIP")
|
|
(packs / "fp_langpack_skip").write_text("de\nfr\n")
|
|
assert _tags(api) == {b"en-us"}
|
|
(packs / "fp_langpack_skip").unlink()
|
|
assert _tags(api) == {b"de", b"en-us", b"fr"} # nothing remembered between lists
|
|
|
|
|
|
def test_everything_else_reaches_the_loader_unchanged(api, packs):
|
|
# a status request for the loader's own asset: its message comes back as it is
|
|
req = api.f("ovr_AssetFile_StatusById", C.c_uint64, C.c_uint64)(7)
|
|
assert req == 100
|
|
m = api.pop()
|
|
assert api.msg_type(m) == STATUSBYID and not api.is_error(m)
|
|
d = api.details(api.f("ovr_Message_GetAssetDetails", VOIDP, VOIDP)(m))
|
|
assert d["path"] == b"/store/a"
|
|
api.free(m)
|
|
assert api.loader.fake_freed() == 1
|
|
assert api.pop() is None
|
|
|
|
|
|
def test_asset_list_without_packs_is_the_loaders_message(api, tmp_path, monkeypatch):
|
|
monkeypatch.setenv("FRAMEPORT_LANGPACK_DIRS", str(tmp_path / "nothing-here"))
|
|
api.f("ovr_AssetFile_GetList", C.c_uint64)()
|
|
m = api.pop()
|
|
arr = api.f("ovr_Message_GetAssetDetailsArray", VOIDP, VOIDP)(m)
|
|
assert api.f("ovr_AssetDetailsArray_GetSize", C.c_size_t, VOIDP)(arr) == 1
|
|
api.free(m)
|
|
assert api.loader.fake_freed() == 1
|
|
|
|
|
|
def test_asset_list_the_loader_never_answers_gets_our_packs_after_the_timeout(api_fast, packs, monkeypatch):
|
|
monkeypatch.setenv("FAKE_SILENT_LIST", "1")
|
|
req = api_fast.f("ovr_AssetFile_GetList", C.c_uint64)()
|
|
assert req == 100
|
|
assert api_fast.pop() is None # not overdue yet
|
|
time.sleep(0.2)
|
|
m = api_fast.pop()
|
|
assert m and api_fast.msg_type(m) == GETLIST and api_fast.req_id(m) == req and not api_fast.is_error(m)
|
|
arr = api_fast.f("ovr_Message_GetAssetDetailsArray", VOIDP, VOIDP)(m)
|
|
size = api_fast.f("ovr_AssetDetailsArray_GetSize", C.c_size_t, VOIDP)(arr)
|
|
tags = {api_fast.details(api_fast.f("ovr_AssetDetailsArray_GetElement", VOIDP, VOIDP, C.c_size_t)(arr, i))["tag"]
|
|
for i in range(size)}
|
|
assert tags == {b"de", b"en-us", b"fr"}
|
|
api_fast.free(m)
|
|
assert api_fast.pop() is None # answered once
|
|
before = api_fast.loader.fake_freed()
|
|
# the dispatcher's answer arrives after all: dropped, not a second answer
|
|
api_fast.loader.fake_late_list(C.c_uint64(req))
|
|
assert api_fast.pop() is None
|
|
assert api_fast.loader.fake_freed() == before + 1
|
|
|
|
|
|
def test_find_tags_in_the_data_folder(tmp_path):
|
|
root = tmp_path / "game"
|
|
(root / "a" / "b" / "c" / "d").mkdir(parents=True)
|
|
for rel in ("de.lang", "a/EN-us.LANG", "a/b/c/fr.lang", "a/b/c/d/too-deep.lang", "bad name.lang", "x.txt"):
|
|
(root / rel).write_bytes(b"1")
|
|
assert langpacks.find_tags(root) == ["de", "EN-us", "fr"]
|
|
assert langpacks.find_tags(None) == [] and langpacks.find_tags(tmp_path / "missing") == []
|
|
|
|
|
|
def _apk(tmp_path, built):
|
|
p = tmp_path / "in.apk"
|
|
with zipfile.ZipFile(p, "w") as z:
|
|
z.writestr("AndroidManifest.xml", b"manifest")
|
|
z.writestr("classes.dex", b"dex\n035\0")
|
|
z.writestr("lib/arm64-v8a/libovrplatformloader.so", built["original"])
|
|
return p
|
|
|
|
|
|
def test_patch_links_the_library_and_hides_the_loaders_functions(built, tmp_path, monkeypatch):
|
|
monkeypatch.setattr(patch_mod, "artifact", lambda abi, name: b"\x7fELF-langpack")
|
|
patch = base.get("frame.langpacks")
|
|
a = _analysis(libs=["libovrplatformloader.so"], extra={"size": 1, "lang_packs": ["de", "en-us"]})
|
|
assert patch.applies(a) # the game's data has language packs
|
|
assert patch.detect(a) is None # opt-in: never part of a suggested recipe
|
|
assert not patch.applies(_analysis(libs=["libovrplatformloader.so"])) # no language packs: nothing to serve
|
|
assert not patch.applies(_analysis(libs=[], extra={"size": 1, "lang_packs": ["de"]})) # no platform loader
|
|
with ApkWorkspace(_apk(tmp_path, built)) as ws:
|
|
ctx = base.ApkContext(ws, a, {}, Reporter(), {})
|
|
assert patch.apply(ctx)
|
|
loader = ws.read_lib("libovrplatformloader.so")
|
|
assert elf.needed(loader)[0] == "libfp_langpack.so"
|
|
assert "ovr_PopMessage" in elf.dyn_symbols(loader, True) # still defined, only no longer global
|
|
assert ws.read_lib("libfp_langpack.so") == b"\x7fELF-langpack"
|
|
assert all(ok for _, ok, _ in patch.validate(ctx))
|
|
assert not patch.apply(base.ApkContext(ws, a, {}, Reporter(), {})) # applied once is enough
|
|
assert any("language packs" in n for n in ctx.notes)
|
|
|
|
|
|
def test_games_already_in_the_library_get_their_language_packs_after_an_app_update(tmp_path):
|
|
"""lang_packs is read from the data folder (no APK analysis): filled in once for existing entries."""
|
|
from frameport.core import library
|
|
|
|
data = tmp_path / "data"
|
|
(data / "obb").mkdir(parents=True)
|
|
(data / "obb" / "de.lang").write_bytes(b"x")
|
|
g = {"data_dir": str(data), "analysis": {"package": "com.x.y", "extra": {}}}
|
|
assert library._refresh_data_fields(g, g["analysis"])
|
|
assert g["analysis"]["extra"]["lang_packs"] == ["de"]
|
|
assert not library._refresh_data_fields(g, g["analysis"]) # once
|