Files
daniel-lynch--ovrplugin-ope…/docs/handoffs/HANDOFF-2026-06-27-native-parity.md
T
Daniel LynchandClaude Opus 4.8 a72a79ad29 Initial public release: OVRPlugin→OpenXR interoperability shim
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
2026-06-29 00:48:48 -04:00

7.5 KiB

RE4 VR Shim — Handoff (2026-06-27, native-parity / P4 passthru)

Branch latency-perffix-copyring. Goal this arc: get the shim to native (stock VrApi) parity. Stock RE4 VR is flawless on this Quest 2; our OpenXR shim had black logos + eye-layer ghosting. Detailed findings in auto-memory (MEMORY.md): native-parity-diagnosis, logo-black-is-layer-zorder, eye-ghost-submission-path-clean-need-p4, three-perf-levers-built.

SHIPPED + VERIFIED this session

Logo black FIXED — it was a layer z-order bug. RE'd it: the 3 intro logos are Oculus splash layers (OculusHMD::FSplash); the game submits [logo-quad, eye-fov] in that order, and OpenXR composites strictly in array order (last = on top), so our opaque eye-fov drew ON TOP of the logo → black. VrApi treats eye-fov as the base regardless of order. Fix: build_composition (shim/src/xr_runtime.c) stable-partitions so projection layers go bottom, quads on top. Gated debug.re4vr.eyebottom (default ON). Device-verified: logos now render. A/B: eyebottom 0.

STILL OPEN: eye-layer ghosting (hand deforms / "three hands", close objects, SEATED mode)

Standing mode = black+reproject instead (load-gated, see standing-vs-sitting-blackflash); seated = clean ghost repro (no reproject confound). Every shim submission metric is CLEAN (all device-verified, see eye-ghost-submission-path-clean-need-p4): depth on didn't fix it; submit is 18ms EARLY not late; VrApi Stale=0; pose render==submit (g_xr.views); FOV render==submit; ffr ruled out; mono cast shows a normal hand in sharp frames → it's a per-eye/stereo artifact we can't see from our side. Hence P4.

NEXT TASK — build the full P4 passthru forwarding (DE-RISKED, viable)

Run the REAL OVRPlugin inside our app and LOG native's actual per-eye poses/FOV/layer submission, to diff against our clean-but-ghosting values. Feasibility PROVEN: PASSTHRU-PROBE: dlopen OK — real lib + libvrapi load in our unofficial app, entry points resolve (probe in core.c passthru_probe() at PreInitialize3, gated debug.re4vr.passthru_probe).

Already in place: SONAME-patched real lib at packaging/libs/arm64/libOVRPlugin_real.so (patchelf --set-soname libOVRPlugin_real.so); repack.sh auto-bundles it when present (verified in the APK). libvrapi.so already rides along from the original APK.

The build (all-or-nothing — real lib must own the whole session to run EndFrame4):

  1. New shim/src/passthru.c: dlopen("libOVRPlugin_real.so", RTLD_NOW|RTLD_LOCAL) once; build a table of real fn pointers (dlsym each ovrp_* the game uses); expose pt_active() + pt_<fn>() accessors (or a single dispatch). Gate: debug.re4vr.passthru (default 0).
  2. In EVERY game-called export (the ~30 in core.c/layers.c + the ~12 stubs the game hits — see below), add at the top: if (pt_active()) { ...optional log...; return real_ovrp_X(args); }. Partial forwarding CRASHES mid-frame, so forward the COMPLETE called set. The game-called set = everything currently implemented in core.c/layers.c PLUS these stubs it invokes (from device log): DestroyDistortionWindow2, SetupDisplayObjects2, SetReorientHMDOnControllerRecenter, SetClientColorDesc, SetAppEngineInfo2, SetAppCPUPriority2, InitializeMixedReality, GetViewportStencil, GetSystemRecommendedMSAALevel2, GetLocalTrackingSpaceRecenterCount, GetLayerTextureFoveation, GetControllerHapticsDesc2. (Confirm the live set by grepping a passthru boot for any of OUR stub ovrp/impl logs that still fire — those are unforwarded calls.)
  3. LOG the ghost-relevant ones: EndFrame4 (per-layer pose+fov+swapchain+flags), GetNodePoseState3(Eye L/R return), GetNodeFrustum2(return), CalculateEyeLayerDesc2, WaitToBeginFrame/EndFrame4 timing. Forward these to real, log args/returns.
  4. Boot test: with passthru 1, does the game run NATIVE through our shim (looks like stock, no ghost)? If yes → capture native EndFrame4 per-eye poses/fov, diff against our values (ours are in the STEREO/HEADvsEYE logs). The delta is the ghost cause. If the game won't init native (Init5 entitlement/session), fall back to static RE of the projection path. CAVEAT: in passthru our shim must NOT also init OpenXR — make xrr_init/frame-loop no-op when pt_active (real lib owns the session). Watch for double-init of VrApi/OpenXR.

The 3 perf levers (built earlier this session — all DEAD ENDS, gated off)

  • Adaptive-res (ovrp_GetAdaptiveGpuPerformanceScale2, core.c): INERT — game's Settings.byte [0x19] bit4 clear, never downscales. debug.re4vr.adaptivescale.
  • Render-ahead (debug.re4vr.renderahead): stable now (old pipeline=1 instability fixed) but does NOT reduce black (still 66-76% under load — black is draw/streaming-bound, not latency). BLACKCOUNT counter (debug.re4vr.blackcount) is the measurement tool.
  • Depth-hold (debug.re4vr.depthhold, needs depth 1): built, untested, ADDS memory pressure.
  • Reproject remains the actual in-game black fix. Corridor black = engine streaming/memory stall (lmkd thrash, Graphics 1.69GB), not GPU — confirmed; GPU knobs don't help. Streaming-throttle CVar is a documented-but-not-built future lever (config-injection DEAD — Shipping ignores external UE4CommandLine.txt; would need CVar memory-patch via IConsoleManager).

Code state (UNCOMMITTED on branch) — RECOMMEND COMMIT before further work

git status: M core.c stubs.c vk_session.c xr_runtime.c xr_runtime.h packaging/repack.sh; new HANDOFF-2026-06-27*.md, save_backup/, packaging/libs/arm64/libOVRPlugin_real.so (986KB binary — needed for passthru; confirm it's committed or staged). +383/-88 in shim. Contains: eyebottom fix (keep — verified), 3 perf levers (gated off), QUADLUMA + per-layer luma diag, passthru probe, depth-aware vk helpers (xrr_vk_alloc_images_ex/xrr_vk_copy_submit_ex).

Build / deploy / device workflow

./shim/build_android.sh && ./packaging/repack.sh           # repack auto-bundles libOVRPlugin_real.so
adb -s <redacted-serial> install -r packaging/out/re4vr-shim.apk   # -r over SAME-signed shim PRESERVES data (no OBB/save dance)
adb -s <redacted-serial> logcat -G 16M                         # big buffer so brief windows don't rotate
adb -s <redacted-serial> logcat | grep -iE "xrr|PASSTHRU"
  • Every cold relaunch needs a manual dismiss of the UnOfficialApp dialog (debug-signed) and the ControllerRequired dialog if controllers asleep → autonomous cold-launch capture is blocked; ask the user to dismiss + boot. Logos play on cold start only.
  • SAVE DISCIPLINE: adb pull /sdcard/Android/data/com.Armature.VR4/files/savegame00.sav BEFORE any uninstall (uninstall wipes app data; cloud doesn't restore unofficial-app saves — lost one this session). Only stock<->shim swaps need uninstall; shim->shim is install -r (preserves data). OBB preserve trick: adb shell mv /sdcard/Android/obb/com.Armature.VR4 /sdcard/obb_bak before uninstall.
  • Stock original APK (pristine, real 907KB OVRPlugin) for A/B: dump/obb/VR4-Android-Shipping-arm64.apk.
  • Current device props: eyebottom 1 reproject 1 depth 1 depthhold 0 renderahead 0 blackcount 1 adaptivescale 1 passthru_probe 1 (set passthru 1 for the new path once built).

RE tooling (works)

capstone 5.0.7 in python (NOT pyelftools — absent; parse ELF phdrs manually, see scratchpad disasm.py pattern). nm -DC for libUE4 dynsym. PluginWrapper offset→name mapping was UNRELIABLE; trust call-site offsets + the dynamic stub ovrp log signal instead.