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.6 KiB
RE4 VR Shim — Handoff (2026-06-26, Lever 2 reproject session)
Continuation of HANDOFF-2026-06-26-skipblack.md. Branch latency-perffix-copyring.
This session built and shipped the working in-game black fix (Lever 2 reproject) and hit
the tuning wall that points to a render-ahead pivot. Detail in auto-memory (MEMORY.md index):
luma-gate-works-bad-vs-benign-black, reproject-works-playable,
reproject-tuning-and-renderahead-pivot, submit-count-cannot-detect-black,
skipblack-blacks-are-sustained-onset-only.
TL;DR — the in-game black is FIXED (reproject), "1000x better than black, playable"
The whole project's black-hunt is functionally solved. On a truncation-black frame we blit the
held last-good eye image back over it and present with the SAVED pose; the compositor timewarps
it to the current head pose instead of flashing black. Committed through 3522e5c.
What was built this session (commits a45362c..3522e5c)
- Post-hitch detector + barcode validation tool (a45362c, c2a6c29): instrument-only black
flag; on-screen frameIndex barcode that turns red on a flagged frame for frame-exact video
correlation (
debug.re4vr.barcode). - Submit-count ruled out (508f55a): UE issues a flat 3 vkQueueSubmits/frame on black AND normal frames — truncation is fewer DRAWS, not fewer submits. Dead end, don't retry.
- Per-frame luma black gate (571aa00):
xrr_vk_frame_lumareads max luma of a few rows of the resolved eye image; covers the whole sustained-black TAIL (post-hitch only caught the onset).debug.re4vr.lumagate,lumathr(default 12). Bad black = luma<thr AND appLayers==1 (appLayers>=2 = a menu's black eye-fov behind a UI quad — benign). - Reproject (7c0882b):
debug.re4vr.reproject=1. Copy-ring hold (L->holdImage, no cross-frame swapchain hold so no pipeline=1 instability). Save acquired->hold on good frames (with pose), restore hold->acquired on black frames, submit with saved pose (g_lastGoodViews, used inbuild_composition). - Tuning (e3bbe85, 3522e5c):
savethr(default 40, only hold clearly-lit frames — fixes dim reprojected frames);abruptdrop(default 0 = reproject all; raise to skip gradual fades);holdevery(default 4, throttle the save copy for perf).
State of play (device-tested)
- Black flashes GONE, playable. Stable, no crashes over multiple soaks.
- Perf ~17ms (~59fps). Breakdown measured: resolve-wait ~7ms + luma ~3.5ms + save ~0.6ms. Folding luma into the resolve cmd buffer = WASH (luma's cost is a forced Adreno tile-resolve from the TRANSFER_SRC transition, not shareable wait overhead — tried + reverted). 72Hz needs attacking the 7ms resolve-wait = render-ahead.
- Remaining artifacts (all inherent to single-frame reprojection): (a) reproject OVERRIDES the game's intentional fades (comfort/teleport/scene/load) — they're black too; abruptdrop helps but can't cleanly separate fade from shallow truncation. (b) long ~1s holds during loads are jarring. (c) head-rotation during a hold shows timewarp edge-black; depth-hold (not done) would add positional reprojection.
NEXT — revisit RENDER-AHEAD (user's strategic call), but verify the premise first
Why now: render-ahead adds 1 frame latency -> UE gets more GPU budget/frame -> FEWER/shorter truncations -> reproject (and its artifacts/long-holds) needed far less; and reproject+luma are now a SAFETY NET for render-ahead's old instability (detect+hide any black instead of flashing).
- GATE (do this first): resolve the contradiction — memory
black-is-not-submit-timingsays UE renders empty regardless of timing, but the user observed black DROPPED when the luma readback (a per-frame GPU sync) turned on. Experiment: use the luma gate to COUNT appLayers==1 truncation-black frames per minute at different added-latency/sync levels (e.g. baseline vs lumagate-on vs an added sync). If latency reduces truncation frequency -> render-ahead is justified; if not -> stay with reproject + tuning + maybe depth-hold. - If justified: build render-ahead informed by the luma signal (present held frame only when
needed, not blindly like the old
pipeline=1), with reproject as the fallback. Seedeferred-flush-unstable-abandonedfor the old failure modes (acquire/release imbalance).
Game perf RE (done this session) — one new lever, several myths busted
Static RE of the GAME binary dump/apk_libs/lib/arm64-v8a/libUE4.so via capstone. Full report:
analysis/game-perf-RE.md; memory game-perf-RE-findings. This REPLACES the behavioral
inferences in game-doesnt-drive-dynamic-perf-from-feed with code proof:
- No game-side dynamic-FFR loop exists —
SetTiledMultiRes{Level,Dynamic}args are config bytes (SetTiledMultiResDynamic(Settings.byte[0x11d])), never compared to GPU time/metrics. So shim-side dynamic FFR is the ONLY path (nothing we return makes the game loop). BootDynamic(0)is just that config value. - Perf-metrics gate hypothesis REFUTED — game never gates FFR on
IsPerfMetricsSupported; perf-metrics feed only a stats/HUD collector. Commit 68a7093 unlocked no behavior. - AppSW dead (
GetLayerTextureSpaceWarpnot even a string in the binary). GetSystemRecommendedMSAALevel2= hazard (over-report -> more MSAA -> more load). Keep at 1.
NEW game-driven lever — implement ovrp_GetAdaptiveGpuPerformanceScale2 (do next session)
At its call site the game does resolution = baseDensity * sqrt(scale) where scale comes from
this API (table slot g+0x130). We return Unsupported -> scale=1.0 -> "viewport always full under
load". If the shim returns scale < 1.0 under GPU pressure (derive from g_gpuFrameMs vs the
13.9ms budget), the game self-downscales resolution — NO game patch. Gated on Settings.byte[0x19] bit4 (adaptive-res enabled) — VERIFY on device. CAVEAT: this is a GPU-LOAD (pixel) lever; the
truncation is DRAW-bound (quarter-res still blacks), so it's a throughput/comfort/headroom win that
might indirectly slacken the render thread — NOT a guaranteed truncation fix. Easy + game-native;
worth stacking, measure truncation effect with the luma counter. (Find the API's stub in
shim/src/stubs.c; we already own g_gpuFrameMs + apply-foveation path.)
Secondary follow-ups (the user "likes them all")
- Depth-hold (artifact reduction): enable depth (works on device, gameplay renders), hold
+restore depth alongside color, chain it (
build_compositionalready chains depth) -> proper positional reprojection. Helps the head-rotation swim/edge-black. - Verify the ghosting-fixed-by-readback observation independently.
Build / deploy / config
./shim/build_android.sh && ./packaging/repack.sh # repack bundles LAST build — check timestamp
adb -s <redacted-serial> install -r packaging/out/re4vr-shim.apk
adb -s <redacted-serial> logcat | grep -iE "xrr|REPROJECT"
Clean device state set this session: reproject=1 holdevery=4 savethr=40 abruptdrop=0 depth=0 trace=0 barcode=0. Launch blocked by controllers-asleep dialog — wake controllers first.
Key reproject toggles (system props, re-read each frame unless noted)
debug.re4vr.reproject 1— the fix (implies luma readback).debug.re4vr.lumathr N(12) — black threshold;savethr N(40) — min luma to HOLD a frame.debug.re4vr.abruptdrop N(0=reproject all; ~25 skips fades but misses shallow truncations).debug.re4vr.holdevery N(4) — save last-good every Nth frame (perf throttle).debug.re4vr.barcode 1— on-screen frameIndex barcode (red = reprojected frame), validation.debug.re4vr.depth 1— experimental depth swapchain (renders OK; not yet held for reproject).