vr.mode in config.yaml picks how Nightcap starts: off on the desktop, on in
VR, auto in VR only when this user's SteamVR is running and its OpenXR
runtime reports a headset. auto never starts SteamVR, and asks its runtime
from a short-lived copy of the process so the runtime's libraries do not load
before the VR backend arms. vr.enabled still works as on/off, and --vr or
--no-vr still override the file for one launch.
Move the empty wait-and-signal batch into internal::ChainPresentDependencies,
like WaitForPresentDependencies, and test that it waits on every present
semaphore, signals the replacement, never blocks the CPU, and leaves a failed
submit to the guest.
Every frame the CPU waited for the GPU to finish Roblox's whole frame in the
desktop mirror, then again for the eye copy, so CPU and GPU work never
overlapped. The mirror blit now consumes the guest's present semaphores and
signals a per-image semaphore that the present waits on. Present preparation
hands remaining waits to the GPU the same way. Eye copies are submitted from a
small ring of command buffers and fences and rely on queue order, so the CPU
only waits when the GPU is three frames behind or the guest destroys an image
a copy still reads.
On Monado at 120 Hz with 1280x1440 eyes this removed 2 to 5 ms of waiting per
frame and kept the frame rate at 118 to 119 fps instead of 103 to 118.
Also free the copy resources when the guest device goes away without a
session, so a later device never reuses stale handles.
The Android client drops HapticService:SetMotor on the Gamepad1 LeftHand and
RightHand motors: its haptics backend only consumes Large and Small. VR games
such as VR Hands drive those motors, so they never vibrated.
- Interpose HapticService::SetMotor through its reflection descriptor, call
the original, and forward Gamepad1 LeftHand/RightHand to OpenXR. The
descriptor and the engine's intensity reader are found by pattern at load,
for both the 2998 (tuple span) and 3222 (shared tuple) reader ABIs. The held
level renews a 50 ms lease every presented frame.
- Report the hand motors at x = -1/+1 in GetMotors. Positional HapticEffects
aim at a hand there with a 0.5 radius (VOXEL DESTRUCT 2 does), so at +-0.2
every effect mixed to zero.
- Play SetMotorState buffers sample by sample with trailing silence trimmed.
Roblox sends 100 x 25 ms look-ahead buffers; taking the peak for the whole
buffer turned a short bump into 2.5 s at full strength.
- Stop vibrating from the DebugDeviceVR haptic slot. The engine pulls it every
frame but with each mix buffer's first sample, held about a second, so it
duplicated the buffers as a long buzz.
- Enable SupportGenericHapticsOnVR and FixRegisterMotors with the other haptic
flags.
The Vulkan adapter exports vk* symbols globally, so a runtime loaded again after it bound its Vulkan calls to the adapter and deadlocked the join. Pinning avoided the reload but left SteamVR with its old VkInstance, which aborted the join with "vkGetInstanceProcAddr: Invalid instance". Later instances now load the runtime libraries with RTLD_DEEPBIND and let them unload with their instance.
- Register HapticProtocol and report motors only when MOCKTAIL_VR_ENABLED is on.
- Drop the per-message [haptic] dispatch log; log each OpenXR haptic result once per hand.
- Write a fast flag only when its name pointer matches the expected name.
- Remove the state-sync fallback that dlsym can never resolve.
- Implement RobloxHapticProtocolBridge for MessageBus HapticProtocol
(SupportsHaptics, GetMotors, SetMotorState, UpdateSingletonVibration).
- Wire bridge into RobloxExperienceComposition and direct game runtime.
- Call JNI NativeGLInterface.nativeUpdateMobileHapticsState during startup
and game completion.
- Enable FFlagKeepHapticsOnMobileWhenGamepadConnected and FFlagVRHaptics in
VR client settings overrides to bypass gamepad haptic suppression in libroblox.
- Directly activate KeepHaptics and VRHaptics fast flag bytes in memory
within RobloxVrDeviceBridge::Activate for builds 2998 and 3222.
- Check xrApplyHapticFeedback result code and add diagnostic logging.
- MOCKTAIL_VR_HAND_POSE_SOURCE=grip now rotates hand records from the grip
pose. base_ori started as the identity quaternion, so the norm check
never fell back to it and hand rotation was dropped.
- Hand records cancel the engine's stored hand pitch (read from
hand_pitch_storage_rva by the pointer hook, 20 until read) instead of a
hard-coded 20, so they agree with the pointer ray.
- Shutdown never rewrites the aim-pointer prologue while guest threads may
be executing it; the jump stays and only forwards once the bridge is gone,
and a later activation reuses it.
- Guest hooks count themselves in flight before loading the bridge pointer;
Shutdown unpublishes the bridge and waits for in-flight calls to drain,
so no hook keeps a pointer to a destroyed bridge.
- Roblox's original state and eye getters are kept for the process
lifetime. A hook reached with no bridge, including after a failed vtable
restore, forwards to them instead of returning an unfilled state buffer.
Restoring the vtable no longer depends on the pointer hook.
- The activation log names the vtable slots in the right order.
- Once the guest eye format is known, the eye swapchains are recreated
once in the format that keeps its bytes: the eye format, else its
UNORM/SRGB sibling (raw copy), else the other channel order with the same
sRGB-ness (a blit that only swizzles). Mixed order-and-gamma cases no
longer go through a gamma-converting blit; if only that remains, it is
logged.
- The desktop mirror segment is created with O_EXCL after removing our own
stale segment (a crashed run's leftover), and must be private to this
user before eye images are written to it.
- A new frame_mutex_ is held for the whole XR frame cycle and by every path
that can destroy the session (taken before mutex_). That lets the cycle
release mutex_ while it blocks in xrWaitFrame, so guest render-thread
hooks no longer wait out the runtime's pacing. Swapchain-image and fence
waits stay under mutex_; they return promptly unless the GPU or
compositor stalls, and they run while guest eye bindings are copied.
- Every frame error, including the swapchain-image and copy-fence
timeouts, now tears the session down recoverably instead of ending XR
output for the rest of the run.
- Session recovery backs off (1, 2, 4, 8 s) and recreates the XrInstance
after an instance-loss event, an instance/runtime failure, or three
failed attempts, so VR returns after SteamVR or WiVRn restarts instead
of retrying forever.
- While no XR session runs (headset idle, session not READY, recovering)
presents are paced at 60 Hz outside the backend lock instead of running
unthrottled.
- A PrepareHostPresent failure that consumed nothing (copy resources
unavailable, failed fence reset or submit) presents normally instead of
failing the guest's vkQueuePresentKHR; only device loss is propagated.
- A runtime/guest GPU mismatch falls back to the plain host device and
keeps the game running without headset output, naming both GPUs.
- A failed EGL attach on the GLES path no longer makes window init fail;
the game runs without headset output and the bind is not retried.
Queue and device-idle locks in the Vulkan adapter are now taken only after
an armed OpenXR backend calls mocktail_vulkan_enable_queue_sharing(). The
guest synchronizes its own queue use; only the runtime and the eye copies
add a second user. Without VR, and in builds without it, submits and
presents take no locks.
FFlagDebugEnableVREmulator is only written when VR is active, so a user's
own fflags.json value is no longer overridden on every launch.
Config loading is back after the single-instance lock and the support
bundle guard. A non-VR build still rejects --vr before creating any data
or state directories, from the command line alone; a vr.enabled config in
such a build is rejected once the config is loaded.
An explicit --vr on a Roblox build without VR support now exits with the
build's version and Build ID, the builds VR supports, and how to select one
(--roblox-lib). When VR is only enabled in the config, the launch continues
without VR and logs the same details. The bridge's own Install error names
both as well. The verified build contracts carry their version labels and
move to namespace scope so they can be listed.
VR now stays off at runtime in every build until --vr or vr.enabled: true,
so a normal launch on a VR-capable build never tries to reach an OpenXR
runtime and no longer exits when none is running.
MOCKTAIL_ENABLE_VR defaults to AUTO: OpenXR support is built when the
third_party/OpenXR-SDK submodule is present and skipped with a notice
otherwise, so source packages that do not fetch submodules (Nix, AUR) keep
building. ON still requires the submodule. mocktail-vr-probe is only built
and installed in VR builds.
CI gains an x86_64 job that builds and tests with MOCKTAIL_ENABLE_VR=OFF.
The config docs and README describe the new defaults.
When Roblox asks for a keyboard (showKeyboard), a world-locked panel
opens below eye level as an OpenXR quad layer. Controllers point at keys
and the trigger types; text and key presses go through the same platform
events as the desktop keyboard, so the focused TextBox edits identically.
The layout is US QWERTY without the function row or Ctrl/Alt, with one-shot
Shift, Caps Lock, Tab, Backspace auto-repeat, Enter and Close. A trigger
aimed at the panel is withheld from Roblox so a key press is not also a
click. A text field above the keys mirrors the focused TextBox, whose
desktop overlay never reaches the eye images. MOCKTAIL_VR_KEYBOARD=0 turns
it off.