Compare commits

...
3 Commits
Author SHA1 Message Date
spoopyghosty0 79e329afe5 Tests: shorter line in the shader-fix triage test 2026-10-24 22:01:35 -04:00
spoopyghosty0 9973833585 Shader fixes: say once when none of a game's configured fixes matched
vk_shader_fix (vkshim) and zink_shader_fix (shader-fix layer) match a shader by size + SHA-256, so a game update that changes the shader made the fix silently do nothing. Once per session, when fixes are configured and none matched 5 minutes after the first shader module (checked only at module creation), the shim/layer logs '<prefix>: N shader fix(es) configured, none matched after M modules in S s (game updated?)'. Triage shader-fix-unmatched (info, report) + PLAYBOOK row. Artifacts rebuilt; no revision bumps.
2026-10-24 21:59:20 -04:00
spoopyghosty0 c239a8cf32 Headset identity: other OVRPort runtimes found by structure, not fixed offsets
The verified 3.4.3-aa54c3f layout keeps its exact guards. Any other dispatcher (the older 3.4.3-23204ea, later
runtimes) gets Quest 3 when Quest 2's name and UUID each occur once and each is loaded by exactly one adr /
adrp+add / adrp+ldr q sequence; otherwise nothing changes and the build keeps Quest 2 with a WARN, as before.
Checked against both runtimes in the OVRPort workspace.
2026-10-24 21:50:16 -04:00
10 changed files with 217 additions and 10 deletions

No files matched your search

+2 -2
View File
@@ -1,5 +1,5 @@
dfeee15b52a530aef0fc90f79140249522adf888d1c88fd57935dd8ce5257fa5 ./arm64-v8a/libVkLayer_fdm_darken.so
5becb96ee86fbda0ce98c0e0fd064f8ba092314aceaa400e3dcc785705dc3c23 ./arm64-v8a/libVkLayer_fp_shaderfix.so
86974202436e9209e260a690ae3eb0082d7d0f88e17c621dd77c0788ea66bc6d ./arm64-v8a/libVkLayer_fp_shaderfix.so
e63d93e086df3b7b29501188b80fb113879e2fd325d7659f68a31ac5bc87b561 ./arm64-v8a/liba32bridge.so
81b073b0cdf11fdb09d22dff682884c5046fe13801f18db9aed232e86f3bf173 ./arm64-v8a/liba32rt.so
18d8d917e83a6d87008f41908eb478dff635e798844e125abd1e8a5cf31805e2 ./arm64-v8a/liba32stub.so
@@ -8,7 +8,7 @@ e63d93e086df3b7b29501188b80fb113879e2fd325d7659f68a31ac5bc87b561 ./arm64-v8a/li
321da502e0f8f46f0880aad39fe6b84bdef88f925164568814cf704a1b31a577 ./arm64-v8a/libfp_ovrp.so
40defdaddcda53bd2649eb48076fae1622bfc2bfc88e9e03c94fc8b45af2a4f2 ./arm64-v8a/libfp_ovrtrace.so
0167c2fde5dfde62897f9d85afc4fc8e97784e988b5b8df6cea8c407240b3424 ./arm64-v8a/libfp_tkeys.so
e32f9b3222bfe2e58c193db809ac2cf54086df36fd2ec6651aa2e58308f6b39d ./arm64-v8a/libfp_vk.so
0055308856070aa7e09d80bcf66ea52dc0a495829b9da64383e38f4350706226 ./arm64-v8a/libfp_vk.so
3c548b567c6984c91bf3f4a2b0bae1336f569f3e9fa9c3204872fd9876f88284 ./arm64-v8a/libfp_xrruntime.so
383054f8b3b41dde76d062c71856cd3163655e50009c08454bc1793f393ead65 ./arm64-v8a/libfpg.so
4a13a33e112c9e4e3c5ff4337ed6e988a9e5b3602f48d654df148ef5fafbf426 ./arm64-v8a/libfpglmv.so
Binary file not shown.
Binary file not shown.
+10
View File
@@ -275,6 +275,16 @@ signatures:
GraphicsEnv functions it uses were not found or behaved differently, for example after a Lepton update), so the game's
OpenGL ES shader fixes (zink_shader_fix) don't apply.
suggest: []
- id: shader-fix-unmatched
# vkshim (vk_shader_fix) / the shader-fix layer (zink_shader_fix), once per session, 5 minutes after the first shader
pattern: '(?:vk shim|shader fix layer): \d+ shader fix\(es\) configured, none matched'
severity: info
diagnosis: This game's recipe has a shader fix (for a GPU hang at one effect or scene), but none of the shaders it
fixes showed up in the first minutes of play. If you haven't reached that part of the game yet, that's expected.
Otherwise a game update may have changed the shader, so the fix no longer applies and the old hang can come back.
If the game freezes or closes there, please send a problem report with diagnostics so the fix can be updated.
suggest: []
report: true
- id: zink-device-lost
pattern: 'zink.*DEVICE[_ ]LOST|VK_ERROR_DEVICE_LOST'
severity: fatal
+1
View File
@@ -203,6 +203,7 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
| OpenGL ES game: memory and GPU mappings grow every frame (~20 MB/s, `/dev/dri/renderD128` mappings ~6 per frame) until the frame rate collapses; the game's `xrclient_<package>.txt` (Steam logs) has one `SXR_GL_CHECK: error:0x502 ... glGenTextures` before the session starts (GitHub #285, Vader Immortal) | The game leaves GL errors pending on its context; the runtime's GL interop reads glGetError after its own calls and takes the stale error as its own failure. From then on `CVRCompositorSharedTextures::BeginCommandBuffer` allocates a new command buffer every frame instead of reusing one (perf page-fault stacks) | Adapter `gl_error_clear=1` (GLES only) clears pending GL errors before xrCreateSwapchain/xrBeginFrame/xrEndFrame; Vader's catalog recipes set it. Verified headless 2026-10-14: 191 GPU mappings and ~1.07 GB flat over 100 s (before: +430 mappings/s, +22 MB/s), no SXR_GL_CHECK |
| Team Beef port slows to a crawl after 5-10 minutes on any map; vrcompositor logs `WaitForPendingPresent: failed to wait for present` more and more often, until its watchdog aborts or the kernel oopses in `drm_gpuvm_validate_deferred_collect` (`msm_ioctl_gem_submit`, from the game's render thread) and the Frame restarts; `xrclient_<package>.txt` has `SXR_GL_CHECK: error:0x500 ... glGenTextures` (GitHub #237, QuestZDoom) | Same runtime GL-interop leak as above: the game leaves GL_INVALID_ENUM pending every frame. `/sys/kernel/debug/dri/0/gem` (readable as steamos) showed the game's GPU VM growing by ~105 objects a second (8,000 after 75 s headless); every GPU submit then has more objects to validate | `adapter.gl_error_clear=1`: suggested for every Team Beef port (`tbxr_libs`), set in QuestZDoom's catalog recipe. Headless 2026-10-24: the game's GPU objects stay flat (1,915 in total over 2 minutes), no SXR_GL_CHECK |
| OpenGL ES game: the GPU hangs at one effect (freeze/exit, compositor watchdog, for example Vader Immortal's lightspeed jump) | a shader (as Zink compiles it) reads loop counters/accumulators before setting them | capture the SPIR-V (adapter `zink_shader_dump=1` writes every module to `files/fp_spirv/`, or a GPU capture), then a `zink_shader_fix` entry (same format as `vk_shader_fix`) + `frame.zink_shader_fix` (Vulkan layer under Zink, activated through GraphicsEnv's debug layer list); log `shader fix layer: fixed shader module N`; triage `zink-shader-fix-mismatch` when a Frame update changed the driver's output |
| A game with a shader fix in its recipe (`vk_shader_fix` / `zink_shader_fix`, for example VR4, Vader Immortal) hangs the GPU again at the effect it was fixed for, after a game update; the log has `… shader fix(es) configured, none matched after N modules in S s (game updated?)` (triage `shader-fix-unmatched`, info) | fixes match a shader by exact size + SHA-256, so a changed shader is silently left alone; the shim/layer logs that line once per session when no configured fix matched 5 minutes after the first shader (checked only at shader creation; a match logs `fixed shader module`). Not reaching the effect yet gives the same line | capture the shaders again (`adapter.vk_shader_dump=1` / `adapter.zink_shader_dump=1`), play to the hang and send diagnostics; the maintainer updates the catalog's fix for the new shader. Never key this on the game's version string |
| 2D Android app: Steam says it's running but nothing shows | Lepton runs apps headless unless the app folder has `lepton-show-flatscreen` | FramePort adds it for apps without VR (agent v28+); reinstall apps installed before |
| A phone/tablet app shows nothing in the headset, or a VR app opens as a flat window | FramePort's VR/2D guess (`vr_kind`) is wrong for this app | patch `device.display_mode` → **Flat window** or **VR** (Customize → "Show as VR or as a flat window"), then Update on Frame |
| An install button opens another program (or nothing) instead of FramePort | another program owns the link type, or the switch is off, or macOS (Flet can't receive links there) | Settings → Install links → **Use FramePort for these links**; else Add games → Install from a link… (paste the button's address) |
+32
View File
@@ -47,6 +47,7 @@
#include <stddef.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <unistd.h>
#define TAG "FrameBridge"
@@ -530,6 +531,36 @@ static VKAPI_ATTR VkResult VKAPI_CALL create_render_pass2_khr(VkDevice device, c
}
static int fixes_applied;
#define WATCH_PREFIX "vk shim"
// Fix watch: a fix matches by exact size + SHA-256, so a game (or driver) update that changes the shader makes it
// silently do nothing. Once per session, when fixes are configured and none has matched WATCH_SECONDS after the first
// module, one line says so (triage `shader-fix-unmatched`); checked only when a module is created, never per frame.
#define WATCH_SECONDS 300
static int watch_matched, watch_done;
static unsigned watch_modules;
static long watch_start;
static long watch_now(void) {
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return (long)ts.tv_sec;
}
static void watch_module(int matched) {
if (!nfixes || __atomic_load_n(&watch_done, __ATOMIC_RELAXED)) return;
if (matched) {
__atomic_store_n(&watch_matched, 1, __ATOMIC_RELAXED);
__atomic_store_n(&watch_done, 1, __ATOMIC_RELAXED);
return;
}
unsigned n = __atomic_add_fetch(&watch_modules, 1, __ATOMIC_RELAXED);
long now = watch_now(), start = 0;
if (__atomic_compare_exchange_n(&watch_start, &start, now, 0, __ATOMIC_RELAXED, __ATOMIC_RELAXED)) return; // first
if (now - start >= WATCH_SECONDS && !__atomic_exchange_n(&watch_done, 1, __ATOMIC_RELAXED) &&
!__atomic_load_n(&watch_matched, __ATOMIC_RELAXED))
LOG(WATCH_PREFIX ": %d shader fix(es) configured, none matched after %u modules in %ld s (game updated?)", nfixes,
n, now - start);
}
EXPORT VKAPI_ATTR VkResult VKAPI_CALL vkCreateShaderModule(VkDevice device, const VkShaderModuleCreateInfo *ci,
const VkAllocationCallbacks *alloc, VkShaderModule *module) {
@@ -540,6 +571,7 @@ EXPORT VKAPI_ATTR VkResult VKAPI_CALL vkCreateShaderModule(VkDevice device, cons
if (dump_on && ci) dump_module(ci->pCode, ci->codeSize); // the game's own module, before any fix
size_t size = 0;
uint32_t *code = nfixes && ci && ci->pCode ? fixed_shader(ci->pCode, ci->codeSize, &size) : NULL;
watch_module(code != NULL);
if (!code) {
VkResult r = real(device, ci, alloc, module);
if (pl_on && ci && module) {
+33
View File
@@ -30,6 +30,7 @@
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <time.h>
#define TAG "FrameBridge"
#define EXPORT __attribute__((visibility("default")))
@@ -225,6 +226,37 @@ static uint32_t *fixed_shader(const uint32_t *code, size_t size, size_t *out_siz
return NULL;
}
#define WATCH_PREFIX "shader fix layer"
// Fix watch: a fix matches by exact size + SHA-256, so a game (or driver) update that changes the shader makes it
// silently do nothing. Once per session, when fixes are configured and none has matched WATCH_SECONDS after the first
// module, one line says so (triage `shader-fix-unmatched`); checked only when a module is created, never per frame.
#define WATCH_SECONDS 300
static int watch_matched, watch_done;
static unsigned watch_modules;
static long watch_start;
static long watch_now(void) {
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return (long)ts.tv_sec;
}
static void watch_module(int matched) {
if (!nfixes || __atomic_load_n(&watch_done, __ATOMIC_RELAXED)) return;
if (matched) {
__atomic_store_n(&watch_matched, 1, __ATOMIC_RELAXED);
__atomic_store_n(&watch_done, 1, __ATOMIC_RELAXED);
return;
}
unsigned n = __atomic_add_fetch(&watch_modules, 1, __ATOMIC_RELAXED);
long now = watch_now(), start = 0;
if (__atomic_compare_exchange_n(&watch_start, &start, now, 0, __ATOMIC_RELAXED, __ATOMIC_RELAXED)) return; // first
if (now - start >= WATCH_SECONDS && !__atomic_exchange_n(&watch_done, 1, __ATOMIC_RELAXED) &&
!__atomic_load_n(&watch_matched, __ATOMIC_RELAXED))
LOG(WATCH_PREFIX ": %d shader fix(es) configured, none matched after %u modules in %ld s (game updated?)", nfixes,
n, now - start);
}
// ---------------------------------------------------------------- dispatch (per instance / device, by dispatch key)
#define MAX_HANDLES 16
@@ -313,6 +345,7 @@ static VKAPI_ATTR VkResult VKAPI_CALL layer_CreateShaderModule(VkDevice device,
if (!next) return VK_ERROR_INITIALIZATION_FAILED;
size_t size = 0;
uint32_t *copy = ci && ci->pCode && ci->codeSize >= 20 ? fixed_shader(ci->pCode, ci->codeSize, &size) : NULL;
watch_module(copy != NULL);
if (!copy) return next(device, ci, a, module);
VkShaderModuleCreateInfo fixed = *ci;
fixed.codeSize = size;
+79 -8
View File
@@ -1,6 +1,8 @@
"""Per-game OpenXR headset identity (GitHub #373)."""
from __future__ import annotations
import struct
from ..base import Param, Patch, Suggestion, register
# Quest 2 deliberately leaves both layers untouched: the compatibility escape hatch.
@@ -17,26 +19,94 @@ UUIDS = {"quest2": bytes.fromhex("3064f23915ed4722a6595ea1ecd899c2"),
SYSTEM_NAMES = {"": b"Meta Quest 3", "quest3s": b"Meta Quest 3S",
"questpro": b"Quest Pro", "quest2": b"Oculus Quest2"}
# Verified OVRPort 3.4.3-aa54c3f arm64 dispatcher: file offsets (VAs differ).
# Guard both data and the instructions that load those exact answers, before changing either.
# Guard both data and the instructions that load those exact answers, before changing either. Other runtimes (the
# older 3.4.3-23204ea, later ones) are matched by structure instead (find_identity).
NAME_OFFSET, UUID_OFFSET = 0x6034, 0x21338
GUARDS = {0x18620: bytes.fromhex("61ffffd021d00091807200910220805203208052c401805262210094"),
0x186e4: bytes.fromhex("1f2003d584620810"),
0x18778: bytes.fromhex("8000c03de006803de2ffff17")}
QUEST2_NAME = b"Oculus Quest2\0"
def _code_references(data: bytes) -> dict[int, int]:
"""{address: number of loads} for the addresses AArch64 code forms with `adr`, or `adrp` + `add` / `ldr q` (the
two ways the verified dispatcher loads systemName and the UUID), over the executable PT_LOAD segments."""
from ...analysis import elf
refs: dict[int, int] = {}
for seg in elf._elf(data).iter_segments():
if seg["p_type"] != "PT_LOAD" or not seg["p_flags"] & 1:
continue
va0, off, size = seg["p_vaddr"], seg["p_offset"], seg["p_filesz"]
words = struct.unpack_from(f"<{size // 4}I", data, off)
for i, w in enumerate(words):
if w & 0x1F000000 != 0x10000000: # adr / adrp
continue
imm = ((w >> 5) & 0x7FFFF) << 2 | (w >> 29) & 3
imm -= (imm & 1 << 20) << 1
pc = va0 + 4 * i
if not w & 0x80000000: # adr
refs[pc + imm] = refs.get(pc + imm, 0) + 1
continue
rd, page = w & 0x1F, (pc & ~0xFFF) + (imm << 12)
for x in words[i + 1:i + 9]:
if (x >> 5) & 0x1F != rd:
continue
if x & 0xFFC00000 == 0x91000000: # add xD, xN, #imm12
target = page + ((x >> 10) & 0xFFF)
elif x & 0xFFC00000 == 0x3DC00000: # ldr qT, [xN, #imm12 * 16]
target = page + ((x >> 10) & 0xFFF) * 16
else:
continue
refs[target] = refs.get(target, 0) + 1
break
return refs
def find_identity(data: bytes) -> tuple[int, int] | None:
"""(systemName file offset, UUID file offset) of an OVRPort dispatcher: the verified 3.4.3-aa54c3f layout, else
found by structure for other runtimes: Quest 2's name and UUID each exactly once in the library, and each loaded
by exactly one instruction sequence of its code. None = not found (nothing is changed)."""
expected = {NAME_OFFSET: QUEST2_NAME, UUID_OFFSET: UUIDS["quest2"], **GUARDS}
if all(data[off:off + len(value)] == value for off, value in expected.items()):
return NAME_OFFSET, UUID_OFFSET
from ...analysis import elf
if not (elf.is_64bit(data) and data[5] == 1 and data[18:20] == b"\xb7\x00"): # little-endian AArch64 only
return None
if data.count(QUEST2_NAME) != 1 or data.count(UUIDS["quest2"]) != 1:
return None
try:
segments = elf.load_segments(data)
refs = _code_references(data)
except Exception: # noqa: BLE001 - unreadable ELF: refuse
return None
def address(off: int) -> int | None:
return next((va + off - o for va, o, size in segments if o <= off < o + size), None)
name_off, uuid_off = data.find(QUEST2_NAME), data.find(UUIDS["quest2"])
if any(refs.get(address(off)) != 1 for off in (name_off, uuid_off)):
return None
return name_off, uuid_off
def patch_dispatcher(data: bytes, key: str) -> bytes:
"""Same-size data-only edit; unknown dispatchers fail atomically. Quest 2 is an exact no-op."""
if key not in SYSTEM_NAMES:
raise ValueError(f"Unknown headset model: {key!r}")
if key == "quest2":
return data
expected = {NAME_OFFSET: b"Oculus Quest2\0", UUID_OFFSET: UUIDS["quest2"], **GUARDS}
if not all(data[off:off + len(value)] == value for off, value in expected.items()):
raise ValueError("Unsupported OVRPort headset identity dispatcher: expected 3.4.3-aa54c3f arm64 "
"systemName/UUID and load instructions; select Quest 2 to preserve its identity.")
found = find_identity(data)
if not found:
raise ValueError("Unsupported OVRPort headset identity dispatcher: its Quest 2 systemName/UUID and the code "
"that loads them weren't found; select Quest 2 to preserve its identity.")
name_off, uuid_off = found
out = bytearray(data)
out[NAME_OFFSET:NAME_OFFSET + 14] = SYSTEM_NAMES[key].ljust(14, b"\0")
out[UUID_OFFSET:UUID_OFFSET + 16] = UUIDS[key]
out[name_off:name_off + 14] = SYSTEM_NAMES[key].ljust(14, b"\0")
out[uuid_off:uuid_off + 16] = UUIDS[key]
return bytes(out)
@@ -96,7 +166,8 @@ class HeadsetModel(Patch):
except ValueError:
# Quest 3 is everyone's default: an APK converted with another OVRPort runtime (e.g. an already converted
# copy) keeps OVRPort's Quest 2 answers instead of failing its build
ctx.checks.append((self.id, None, "This OVRPort dispatcher isn't a known build; the game keeps seeing a "
ctx.checks.append((self.id, None, "This OVRPort dispatcher's headset identity (Quest 2 name and UUID "
"and the code that loads them) wasn't found; the game keeps seeing a "
"Quest 2 (only FrameBridge's systemName override applies)."))
return False
ws.put(ws.lib(loader), fixed)
+48
View File
@@ -189,6 +189,51 @@ def test_dispatcher_rejects_unknown_and_partial_matches():
assert patch_dispatcher(b'unknown runtime', 'quest2') == b'unknown runtime'
def _elf_dispatcher(name_refs=1, uuid_refs=1, extra_name=False):
"""A small AArch64 ELF laid out like another OVRPort runtime: one R+X PT_LOAD (vaddr == offset) with the code and
the name, one RW PT_LOAD 0x8000 above its file offset with the UUID; the code loads the name with adrp + add and
the UUID with adr, like the verified dispatcher."""
import struct
from frameport.patches.frame.headset_model import QUEST2_NAME, UUIDS
data = bytearray(0x3000)
data[:16] = b"\x7fELF\x02\x01\x01" + bytes(9)
struct.pack_into("<HHIQQQIHHHHHH", data, 16, 3, 183, 1, 0, 64, 0, 0, 64, 56, 2, 64, 0, 0)
struct.pack_into("<IIQQQQQQ", data, 64, 1, 5, 0, 0, 0, 0x2000, 0x2000, 0x1000)
struct.pack_into("<IIQQQQQQ", data, 120, 1, 6, 0x2000, 0xA000, 0xA000, 0x1000, 0x1000, 0x1000)
name_off, uuid_off, uuid_va = 0x1834, 0x2340, 0xA340
data[name_off:name_off + 14] = QUEST2_NAME
data[uuid_off:uuid_off + 16] = UUIDS["quest2"]
if extra_name:
data[0x1900:0x1900 + 14] = QUEST2_NAME
code = 0x400
for _ in range(name_refs): # adrp x1, page(0x1834) = +1 page ; add x1, x1, #0x834 (code in page 0)
struct.pack_into("<II", data, code, 0x90000001 | 1 << 29, 0x91000021 | 0x834 << 10)
code += 8
for _ in range(uuid_refs): # adr x4, uuid
delta = uuid_va - code
struct.pack_into("<I", data, code, 0x10000004 | (delta & 3) << 29 | ((delta >> 2) & 0x7FFFF) << 5)
code += 4
return bytes(data), name_off, uuid_off
def test_other_runtime_found_by_structure():
"""OVRPort runtimes other than the verified one (the older 3.4.3-23204ea; later ones) keep the Quest 3 default:
the name and UUID are found by content and by the code that loads them, not by fixed offsets."""
from frameport.patches.frame.headset_model import SYSTEM_NAMES, UUIDS, find_identity, patch_dispatcher
blob, name_off, uuid_off = _elf_dispatcher()
assert find_identity(blob) == (name_off, uuid_off)
fixed = patch_dispatcher(blob, "")
assert fixed[name_off:name_off + 14] == SYSTEM_NAMES[""].ljust(14, b"\0")
assert fixed[uuid_off:uuid_off + 16] == UUIDS[""]
assert sum(a != b for a, b in zip(blob, fixed, strict=True)) <= 30
for kw in (dict(name_refs=0), dict(uuid_refs=0), dict(name_refs=2), dict(extra_name=True)):
other = _elf_dispatcher(**kw)[0]
assert find_identity(other) is None # not loaded once by the code / not unique: refused, unchanged
with pytest.raises(ValueError, match="Unsupported OVRPort"):
patch_dispatcher(other, "")
def test_unknown_dispatcher_keeps_quest2_instead_of_failing_the_build():
"""Quest 3 is the default for every Quest game: an APK converted with another OVRPort runtime (found with an
already converted Batman copy) must still build, keeping OVRPort's Quest 2 answers."""
@@ -218,6 +263,9 @@ def test_real_dispatcher_when_available():
for key in MODELS:
fixed = patch_dispatcher(original, key)
assert len(fixed) == len(original)
older = path.parents[3] / '3.4.3-23204ea/lib/arm64-v8a/libopenxr_loader.so'
if older.is_file(): # the older runtime: found by structure (offsets differ)
assert patch_dispatcher(older.read_bytes(), '') != older.read_bytes()
@pytest.mark.parametrize('key', MODELS)
+12
View File
@@ -124,6 +124,18 @@ def test_shader_fix_layer_findings():
assert "adapter.zink_shader_dump" in r.suggestions()
def test_shader_fix_unmatched():
for prefix in ("vk shim", "shader fix layer"):
log = LOG_OK + (f"09-28 17:39:06.000 1147 1174 I FrameBridge: {prefix}: 2 shader fix(es) configured, "
"none matched after 812 modules in 300 s (game updated?)\n")
r = triage(log, "RUNNING", "com.example.game")
f = next(f for f in r.findings if f.id == "shader-fix-unmatched")
assert f.severity == "info" and r.verdict != "fail"
# a fix that matched logs its own line, not this one
log = LOG_OK + "09-28 17:39:06.000 1147 1174 I FrameBridge: vk shim: fixed shader module (100 -> 108 bytes): 0\n"
assert "shader-fix-unmatched" not in {f.id for f in triage(log, "RUNNING", "com.example.game").findings}
def test_launcher_signature():
r = triage("lepton: APP_ACTIVITY is empty\n", "NEVER_STARTED")
assert r.suggestions() == ["frame.launcher"]