An independent reimplementation of Meta's libOVRPlugin ABI on top of OpenXR, so VrApi-era Meta Quest VR titles can run on non-Meta OpenXR runtimes (Monado, Steam Frame) instead of being locked to Meta hardware. Original code only — no Meta/Epic/Capcom binaries, headers, or assets. Includes a desktop harness that drives the shim against Monado headless. Scope/legal: interoperability; entitlement handling is out of scope. See README for the legal/scope section and docs/ for the research trail and design notes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D6sFYGXZPsq3v7xtcDES6g
7.7 KiB
RE4 VR Shim — Handoff (2026-06-27, 3-levers + streaming-bound reframe)
Branch latency-perffix-copyring. Prior: HANDOFF-2026-06-26-reproject.md. Auto-memory:
three-perf-levers-built, plus the prior reproject/black chain. This session built the 3
forward perf levers, soak-tested them, and reframed the in-game black as a level-streaming /
memory-bandwidth stall (NOT GPU-bound) — which retires the GPU levers and points at a UE
async-streaming CVar as the only remaining cause-fix.
What was built (uncommitted on the branch; compiles clean, gated, device-tested)
- Lever A — dynamic resolution
ovrp_GetAdaptiveGpuPerformanceScale2(real impl incore.c, helperxrr_adaptive_gpu_scale()inxr_runtime.c, stub removed fromstubs.c). Propsdebug.re4vr.adaptivescale(1),adaptivefloor(60). DEVICE RESULT: inert — zeroVIEWPORT SUBunder extreme load ⇒ game'sSettings.byte[0x19] bit4is clear, game ignores our scale (the handoff caveat held). - Lever B — render-ahead + truncation counter. Shared
reproject_resolved_eye()now feeds both present paths; render-ahead viadebug.re4vr.renderahead 1(pose-patchesg_pendingto last-good on a black held frame; clean engage/disengage drain + presentIndex guard).BLACKCOUNTcounter (debug.re4vr.blackcount, default 1). DEVICE RESULT: render-ahead is STABLE (old pipeline=1 instability fixed) but NOT a black cure — still spiked to 66–76% black withpipelined=1. Confirms black is draw/CPU-bound, not latency-bound. Counter works perfectly and was the instrument for everything below. - Lever C — depth-hold (
debug.re4vr.depthhold 1, default 0; needsdepth 1; sync path only). Depth-awarexrr_vk_alloc_images_ex/xrr_vk_copy_submit_ex(isDepth). No crash from the depth barriers, but it ADDS graphics memory in the exact pressured zone — net negative for the streaming-stall black; left OFF.
THE REFRAME (this session's real finding) — black is a STREAMING/MEMORY stall
User observation: the long black/reproject episode was in a corridor next to a load-zone door — which has fewer draws than the open areas that render fine. That contradicts "draw-bound." The trace agrees:
dumpsys meminfo: Graphics 1.69 GB, TOTAL RSS 2.87 GB (Quest 2). During the black window (02:06:41–45) the system loggedRescueParty: lmkd_native+ActivityManager: Lost connection to lmkd— the low-memory killer daemon fell over. Severe memory thrash.- Black frames were at near-normal 72Hz cadence (dt ~21–24ms), only ~8 true hitches in 25s but 240–275/360 frames black ⇒ GPU keeping cadence while UE hands us empty frames ⇒ the game thread is stalled (streaming the next zone in), not the GPU. Conclusion: near a load door UE pre-streams the adjacent zone's textures → memory spike → page reclaim/lmkd thrash → game-thread stall → empty (black) eye frames. GPU knobs (resolution, FFR, render-ahead) cannot help; they target a bottleneck that isn't the limiter.
Mitigation that DID help (memory, not pixels)
Config renderahead=0 reproject=1 depth=0 depthhold=0 sscap=1: peak black 76% → ~16%, most
windows 0%. Note sscap did nothing (eye buffer stayed 1728×1900 — already the recommended
size, no supersample to cap); the win came from depth=0 freeing graphics memory. Cleanest
possible proof it's memory-bound. Reproject hides the residual ⇒ playable.
Recommended shipping config: renderahead=0 reproject=1 holdevery=4 savethr=40 abruptdrop=0 depth=0 depthhold=0 blackcount=1 adaptivescale=1 adaptivefloor=60 (adaptivescale harmless even
if inert).
NEXT TASK — throttle UE async streaming via a CVar (the only remaining cause-fix)
User's instinct: deprioritize the background stream-ahead, accept a longer load screen. Maps to
UE4 CVars: s.AsyncLoadingTimeLimit, s.LevelStreamingActorsUpdateTimeLimit,
s.PriorityAsyncLoadingExtraTime, s.AsyncLoadingUseFullTimeLimit, and memory-side
r.Streaming.PoolSize / r.Streaming.LimitPoolSizeToVRAM / prefetch distance. Lowering the
async time limits spreads streaming over more frames (less per-frame game-thread theft → smoother
active scene, slower stream). Lowering the streaming pool caps the pre-spike.
CHEAP path PROBED & DEAD (don't retry the same way)
External UE4CommandLine.txt override is NOT read by this Shipping build. Tested
-execcmds="t.MaxFPS 20" at both /sdcard/UE4Game/VR4/UE4CommandLine.txt and the app-scoped
/sdcard/Android/data/com.Armature.VR4/files/UE4Game/VR4/UE4CommandLine.txt; end_frame
heartbeat stayed at 71 fps (decisive — heartbeat = game render rate; VrApi FPS=72 is just the
reprojecting compositor, ignore it). The engine only uses the baked assets/UE4CommandLine.txt
(../../../VR4/VR4.uproject). Minor caveat: t.MaxFPS could be VR-overridden, but two paths
- heartbeat make "file not read" the strong read.
- Secondary cheap probe worth ONE try before memory-patching:
GameUserSettings.iniscalability —GGameUserSettingsIniis read from a writable app-scoped Saved/Config path. Write[ScalabilityGroups] sg.TextureQuality=0 / sg.ViewDistanceQuality=1and see if the texture-streaming pool shrinks (watchdumpsys meminfoGraphics + BLACKCOUNT in the corridor). Uncertain (game may overwrite settings via its own UI), but declarative if it sticks.
ROBUST path — set the CVar from the shim (the actual handoff task)
The console-variable machinery is present and several helpers are exported (T) in
dump/apk_libs/lib/arm64-v8a/libUE4.so:
_Z22CreateConsoleVariablesv,_Z27ForEachCVarInSectionFromIni..., and (seen via strings)LoadConsoleVariablesFromINI, plusIConsoleVariable/IConsoleManagerinfra andOnCVarChange. KismetGetConsoleVariable{Int,Float,Bool}Valueexist too.- Plan: after UE init (we already dlsym-patch UE's exported globals for the submit hook — same
technique), resolve
IConsoleManager::Get()thenFindConsoleVariable("s.AsyncLoadingTimeLimit")and call->Set("2", ECVF_SetByCode). RE steps: (a) findIConsoleManager::Get(likely a local symbol — use the full symbol table / capstone like the game-perf RE;CreateConsoleVariablesvand theRegisterConsoleVariablecallsites reference the manager singleton). (b) confirm theIConsoleVariable::Set(const TCHAR*, EConsoleVariableFlags)vtable slot ABI. (c) call it for the 3–4 streaming CVars at a safe point (first frame). Gate behinddebug.re4vr.streamthrottle. - Verify: corridor soak,
BLACKCOUNTdelta +dumpsys meminfoGraphics, and confirm load transitions still complete (just slower). The counter is already the measurement tool.
IS THIS NECESSARY OR OVER-ENGINEERING? (honest call)
Leaning over-engineering for the value. The streaming-stall black is already well-mitigated (reproject + depth-off: 76%→16% peak, mostly 0%, residual hidden as a brief hold). The CVar memory-patch is invasive RE (console-manager ABI, fragile across game updates) for a marginal win on a transient that's already hidden. Recommend: stop at the memory-light + reproject config, document the CVar route as a known lever, and only build it if the corridor reproject-holds are perceptually annoying enough to the user to justify it. Try the GameUserSettings probe first (cheap) if pursuing further.
Build / deploy / config
./shim/build_android.sh && ./packaging/repack.sh # check libOVRPlugin.so timestamp before repack
adb -s <redacted-serial> install -r packaging/out/re4vr-shim.apk
adb -s <redacted-serial> logcat | grep -iE "xrr|REPROJECT|BLACKCOUNT|VIEWPORT SUB"
Files changed this session: core.c, stubs.c, vk_session.c, xr_runtime.c/.h (+316/−89).
Not yet committed.