Compare commits

...
222 Commits
Author SHA1 Message Date
vii adccf4e484 Merge branch 'main' into vr 2026-10-16 01:22:27 -05:00
vii c4be3de14a fix(runtime): start a queued game after leaving the current one 2026-10-16 01:19:29 -05:00
dim-ghub 8370850bb7 docs(changelog): merge the two Changed sections under Unreleased 2026-10-16 01:14:12 -05:00
vii 876f8b8c32 perf(runtime): compile menu Lua to native code and switch it off in games 2026-10-16 01:13:49 -05:00
dim-ghub 1854e00934 feat(vr): add SMAA as a vr.anti_aliasing choice
vr.anti_aliasing now takes fxaa, smaa or off. SMAA runs the reference
implementation (third_party/smaa, MIT) at its High preset: luma edge
detection and blending weights into per-eye images, then neighborhood
blending into the runtime's swapchain through FXAA's final pass. Its area and
search lookup textures are uploaded once per device. If SMAA cannot be set
up, the eyes fall back to FXAA.

Also export vr.anti_aliasing from config.yaml: the VR backend reads the
process environment, so the setting only worked as an environment variable.

On a Quest 2 through SteamVR at 120 Hz (2136x2236 per eye), SMAA ran at 118.6
fps against 118.8 for FXAA, with p99 frame times of 10.1 and 9.5 ms.
2026-10-16 01:12:21 -05:00
vii c614fba032 feat(profile): trace emulated ETC2 texture VRAM and report its peak
Emulated images keep a running total of their decoded and ETC2/EAC sizes over every mip and layer. --profile writes both as counters, and summarize_profile_trace.py prints their peaks.
2026-10-16 00:53:05 -05:00
dim-ghub 0221738952 Merge remote-tracking branch 'origin/main' into vr 2026-10-16 00:52:17 -05:00
dim-ghub 046632cf99 fix(input): render emoji in the TextBox overlay (#24)
The overlay's fallback fonts were the first few fontconfig sans matches,
which rarely include an emoji font. Fall back to Roblox's bundled
TwemojiMozilla and RobloxEmoji fonts, then the system emoji font, before
the sans list.
2026-10-16 00:50:10 -05:00
dim-ghub 753a3f8106 feat(vr): anti-alias the headset eyes with FXAA
Roblox's VR debug device renders both eyes into single-sample textures, so
edges were jagged in the headset. When the eyes go to the runtime's
swapchain, an FXAA draw now replaces the plain copy. It samples the eye
directly, and re-encodes for sRGB swapchains so the guest's bytes round-trip
exactly. Anything it cannot set up falls back to the copy.

vr.anti_aliasing (fxaa, the default, or off) controls it. The SPIR-V is
generated from shaders/vr by scripts/generate_vr_shaders.py and checked in,
so builds need no shader compiler; a test checks it against the sources when
glslc is installed.

On a Quest 2 through SteamVR at 90 Hz (2244x2352 per eye) frame times did not
change. Captured output keeps 97 to 98 percent of pixels byte-identical and
the average brightness unchanged, and smooths the edges.
2026-10-15 20:19:17 -05:00
dim-ghub 569df68729 fix(vr): stop trapping the desktop cursor in the window during VR
Roblox's mouse lock and the RMB camera drag put the window into relative
mouse mode with a grab, which locked the cursor inside it while the headset
was in use. In VR the controllers aim and the window only mirrors the left
eye, so the pointer is never captured and the system cursor stays visible.
2026-10-15 19:55:35 -05:00
dim-ghub 043964a5dc fix(vr): say why auto found no headset, and document vr.mode
Without a headset SteamVR refuses the OpenXR client at xrCreateInstance
(VRInitError_Driver_WirelessHmdNotConnected) instead of reporting no system,
so say that it usually means no headset is connected. Describe vr.mode in the
README, docs/VR.md and the changelog.
2026-10-15 19:42:40 -05:00
dim-ghub ea248b1ecf feat(vr): add vr.mode with on, off and auto
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.
2026-10-15 19:37:32 -05:00
dim-ghub 35033454b0 docs(vr): note the frame-stall fix and the new perf stages 2026-10-15 17:21:09 -05:00
dim-ghub df025d2eb7 fix(vr): publish the shared-memory mirror a frame later instead of waiting
With the eye copies no longer waited for, MOCKTAIL_VR_MIRROR_SHM made the CPU
wait for the whole frame before publishing, which cut the frame rate from 117
to 82 fps at 120 Hz. Publish each readback at the next copy, when the GPU has
finished it. The external viewer sees each frame one frame later.
2026-10-15 14:58:26 -05:00
dim-ghub dea1d5fd59 test(vr): cover handing present dependencies to the GPU
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.
2026-10-15 11:43:51 -05:00
dim-ghub 9ae5667cd7 perf(vr): read per-frame settings once and skip the lock for other passes
The frame cycle read the pose source, evidence and mirror settings from the
environment every frame. Read them once. Check the thread-local eye request
before taking the backend lock, so render passes that are not eye passes do
not contend with the frame cycle.
2026-10-15 08:19:04 -05:00
dim-ghub 7296520064 perf(vr): order eye copies and the desktop mirror on the GPU
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.
2026-10-15 04:52:37 -05:00
vii a3a2b76a06 feat(update): add an evidence-scoring solver for HostAbi addresses with a check command 2026-10-15 01:49:19 -05:00
vii f333399e18 test(update): add a 2908 to 3222 regression set for the HostAbi deriver
Share the input checks and FMOD slot tables so the solver reuses them.
2026-10-15 01:49:19 -05:00
dim-ghub e094a4c89d feat(vr): time the desktop mirror, present prepare and frame interval
MOCKTAIL_VR_PERF now reports how long the CPU waits in the desktop mirror and
in present preparation, and the time between presents, so per-frame stalls
outside the XR frame cycle show up in the numbers.
2026-10-15 01:37:12 -05:00
vii de0ddf7864 feat(update): solve the VR device bridge and compare it with the VR offset finder 2026-10-15 02:20:13 -05:00
vii e553db2001 fix(vr): point 3092 FixRegisterMotors at the flag Roblox reads
The 3092 table had the address of another field in the same registration, so the flag never turned on.
2026-10-15 02:20:13 -05:00
vii 44ec71f826 Merge branch 'main' into vr
# Conflicts:
#	FAQ.md
2026-10-15 01:57:30 -05:00
vii 3062909209 refactor(jnivm): resolve method names once and log calls with no handler
GetMethodID maps each name to a JniMethod enum, so dispatch compares
numbers instead of strings. Calls that reach no handler print one
"no handler for" line per class, method and signature. Unknown
gameActivity_ callbacks are recorded on the CallVoidMethod path too.
The FMOD write path reuses a per-thread buffer instead of allocating.
2026-10-15 01:31:02 -05:00
vii 049784d0f8 fix(docs): restore the screenshots at full quality with transparency 2026-10-15 01:31:02 -05:00
vii ccf910f064 chore(ci): keep the signing and AUR keys in a release environment
The release environment only accepts main and v* tags, so a workflow pushed on another branch cannot read the keys. Flatpak builds that do not publish run without it.
2026-10-15 01:26:27 -05:00
dim-ghub 42b5138b11 fix(vr): deliver hand-motor and HapticEffect haptics the way games aim them
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.
2026-10-15 01:12:01 -05:00
vii 2eaba9048b fix(vr): reload the OpenXR runtime with its own Vulkan loader instead of pinning it
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.
2026-10-14 22:31:55 -05:00
vii a2ffa5c811 Merge branch 'vr' of CoderDayton/nightcap into vr 2026-10-14 04:29:10 -05:00
vii 2be319a979 fix(vr): keep the OpenXR runtime loaded so a game join does not deadlock 2026-10-14 04:19:58 -05:00
vii 6cf641d472 feat(vr): find the haptic flags with the offset finder and enable them on 3092 2026-10-14 03:14:16 -05:00
vii 3788026d10 fix(vr): limit haptics to VR sessions and check flag names before writing
- 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.
2026-10-14 03:14:16 -05:00
dim-ghub f3e68fc3b7 feat(vr): implement OpenXR controller haptics and bypass mobile suppression
- 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.
2026-10-14 01:32:32 -05:00
vii 23e7831149 fix(site): keep the screenshots transparent
The first retake was flattened to RGB, which turned the transparent window edges black.
2026-10-13 21:34:07 -05:00
vii 5b2ee2c7f3 docs(site): rewrite the description and feature list, add SEO tags, refresh screenshots
New intro and feature list in the README and the site. The site gets a title with Roblox, link preview tags, structured data, a sitemap, robots.txt, a preview image and the Search Console tag. Screenshots are retaken, the old lobby shot is removed and the store listing points at the new ones.
2026-10-13 21:29:12 -05:00
vii 63ed62b4aa docs(faq): point the VR answer at Nightcap's vr branch 2026-10-13 20:21:33 -05:00
vii 0b0d8067d8 docs(vr): list supported builds, move VR detail into docs/VR.md and update the FAQ 2026-10-13 20:20:51 -05:00
vii 87ac6ee572 test(vr): cover the keyboard upload and quad layer on a real OpenXR runtime 2026-10-13 19:58:40 -05:00
vii fcc8b5382a fix(vr): turn off the desktop flyout side panel in VR 2026-10-13 19:58:40 -05:00
vii 9826535102 fix(vr): align the IsVRAppBuild hook so unoptimized builds do not crash Roblox 2026-10-13 19:58:40 -05:00
vii a30f05fc5b fix(vr): keep the bridge and backend alive until exit for guest threads still in a hook 2026-10-13 18:37:35 -05:00
vii 1ff05e4e6f fix(vr): do not recover an XR session when the restarted runtime uses another GPU 2026-10-13 18:37:35 -05:00
vii 123968be89 test(assets): pin overlay priority and path safety when the root is the content folder 2026-10-13 18:37:35 -05:00
vii 393297a439 feat(vr): support Roblox 3222 (2.742.1059) 2026-10-13 18:37:35 -05:00
vii 7ec8304d8f fix(vr): stop keyboard click-through, redraw churn and upload leaks; build its drawing only with VR 2026-10-13 18:37:34 -05:00
vii f9289d9947 fix(vr): read the pointer hand and HeadScale offsets per build and check them at start-up 2026-10-13 18:37:26 -05:00
vii 7294a46e8a fix(vr): turn on Vulkan queue locking when the adapter loads after the backend 2026-10-13 18:37:19 -05:00
vii 4b67176970 fix(scripts): find VR offsets when Roblox moves the pointer fields
Build 3222 moved the hand selector, hand frame and camera HeadScale fields, so the fixed byte patterns found nothing. Match the code shape instead, read the three offsets from it and write them to the output.
2026-10-13 17:15:39 -05:00
dim-ghub 228121157b vr: drop test hooks and dead code, document every VR variable
- Remove the MOCKTAIL_VR_TEST_SESSION_LOSS_AFTER fault injection; nothing
  used it.
- The scripted head pose and the eye evidence dumper only work in builds
  configured with -DMOCKTAIL_VR_DIAGNOSTICS=ON (default OFF); release
  builds ignore their environment variables.
- Remove FindFmodInputCaptureProfile and HasExpectedFmodInputCaptureVtable
  (only their own tests called them) and the never-defined
  PrepareCaptureLocked declaration; the audio bridge files match upstream
  again.
- docs/VR.md lists every MOCKTAIL_VR_* variable with its default, linked
  from the README.
2026-10-13 00:28:26 -05:00
dim-ghub a06c7de93e vr: fix bridge hand math and make its shutdown safe for live guest threads
- 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.
2026-10-13 00:24:45 -05:00
dim-ghub 8106203c8e vr: fix eye colour formats, mirror segment safety and frame-wait locking
- 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.
2026-10-13 00:08:22 -05:00
dim-ghub 0603734ff6 vr: recover from XR failures instead of giving up on headset output
- 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.
2026-10-13 00:04:42 -05:00
dim-ghub 0b5578d80b vr: keep non-VR launches on their original paths
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.
2026-10-12 23:59:46 -05:00
dim-ghub bafbe4268c vr: name the build and the supported builds when VR is unavailable
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.
2026-10-12 23:55:37 -05:00
dim-ghub 3ef81c3d93 vr: make VR opt-in and build without the OpenXR submodule
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.
2026-10-12 23:53:31 -05:00
dim-ghub 360ab6af58 vr: fix CI failures from the 3092 profile
Mark the 3092 profile experimental. It still runs when selected with
--roblox-lib, but it no longer becomes the preferred update target for
every x86_64 user, so the shipped payload metadata (2998) is again the
preferred profile.

Select the 2998 entry by Build ID in the partial-input profile test instead
of relying on manifest order, and drop the shipped 3092 entry before the
test re-identifies 2998 as 3092.

Align the example config's VR build wording with the bootstrap template.
2026-10-12 23:48:32 -05:00
dim-ghub 4cc0e41ef3 vr: add in-headset virtual keyboard for TextBox input
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.
2026-10-12 23:45:51 -05:00
dim-ghub ddc985218b vr: add separate VR frame-rate limit, render scale and unpaced benchmark mode
vr.frame_rate_limit / MOCKTAIL_VR_FRAME_RATE_LIMIT replaces
graphics.frame_rate_limit while VR is active and takes the same values.
It defaults to unlimited so xrWaitFrame paces Roblox at the headset
refresh rate instead of Roblox's own 60 FPS cap.

MOCKTAIL_VR_RENDER_SCALE scales the runtime's recommended eye extent.
SteamVR's recommendation for a Quest 2 at 90 Hz (2244x2352 per eye) missed
the 11.1 ms budget and halved the rate to 45 FPS.

MOCKTAIL_VR_UNPACED=1 stops guest frames from blocking in xrWaitFrame:
the open XR frame spans several guest frames and is submitted once per
display period, for measuring maximum render rate.
2026-10-12 22:44:46 -05:00
dim-ghub 33dd7769f0 Merge remote-tracking branch 'upstream/main' into vr
# Conflicts:
#	stubs/libandroid_stub.cc
2026-10-12 21:50:15 -05:00
dim-ghub 88bb4c940c vr: align pointer ray pitch with hand orientation and reset world scale to 1.0 2026-10-12 21:28:20 -05:00
dim-ghub 68f8ec82d2 vr: fix sRGB double-gamma, home world avatar spawn, and controller aim alignment 2026-10-12 20:25:21 -05:00
dim-ghub 509822974b vr: Recreate OpenXR instance on guest Vulkan instance recreation
SteamVR (vrclient.so) binds its Vulkan renderer to the lifetime of the
XrInstance. When Roblox destroys the LuaApp Vulkan device and creates a
second VkInstance/VkDevice for UGC experiences, reusing the original
XrInstance causes SteamVR to dereference freed device handles in xrEndFrame.

Tear down and recreate the XrInstance whenever a new guest Vulkan instance
is initialized, resetting session state, spaces, and controller actions.
2026-10-12 05:42:33 -05:00
dim-ghub a441fa7110 assets: fix shader pack path resolution and root directory normalization 2026-10-12 02:16:21 -05:00
dim-ghub d54d20de64 audio: align input capture test with shared_ptr atomic store and restore FMOD profile functions 2026-10-12 01:45:08 -05:00
vii 81de528668 feat(packaging): publish nightcap and nightcap-bin to the AUR on release
Pushes nothing until the AUR_SSH_PRIVATE_KEY secret is set. Recipes move to 0.3.4.
2026-10-12 01:44:21 -05:00
vii ff81882cb0 docs: point Arch users to nightcap-git on the AUR
The package is maintained by @dim-ghub.
2026-10-12 01:44:21 -05:00
dim-ghub f9fede1649 Merge remote-tracking branch 'mocktail/vr' into vr 2026-10-12 01:39:13 -05:00
vii a87373d003 feat(assets): let the overlay replace files Roblox downloads
Roblox maps some rbxasset:// names to downloads and opens no local file for
them. For each file under content/ in the overlay folder, change its name in
the loaded libroblox so Roblox opens the local file.

Idea from @dim-ghub in #20.
2026-10-12 01:20:13 -05:00
vii b46098c211 feat(assets): trace asset lookups that find no file 2026-10-12 01:20:13 -05:00
vii 144350090e docs: describe the asset overlay folder 2026-10-11 23:58:26 -05:00
vii 3ed0418997 refactor(assets): share the asset lookup cache insert 2026-10-11 23:58:26 -05:00
vii 8410070eec Merge pull request #19 from dim-ghub/main
Add Sober-compatible asset overlay system
2026-10-11 23:56:58 -05:00
dim-ghub a1166fb6ba Add Sober-compatible asset overlay system 2026-10-11 23:11:54 -05:00
vii c9eb4fb7f0 docs(changelog): credit @qutad for the anti-aliasing fix 2026-10-10 22:02:40 -05:00
vii b59c070cc0 docs(changelog): release 0.3.4
Report Nightcap's own version instead of Mocktail's.
2026-10-10 21:27:00 -05:00
vii 4a9acf8c49 feat(site): redesign the landing page and legal pages 2026-10-10 21:07:33 -05:00
vii c342bab7b5 docs(changelog): note the voice chat fix after a Roblox update 2026-10-10 00:33:00 -05:00
vii a3b9c872a5 fix(update): derive the FMOD bridge from the shipped reference when the installed profile lacks it 2026-10-10 00:33:00 -05:00
vii 4f45baf3f7 docs(changelog): note the anti-aliasing and voice chat microphone fixes 2026-10-10 00:09:42 -05:00
vii 0ddb546ba8 chore(merge): merge upstream komaruworld/mocktail main 2026-10-10 00:09:42 -05:00
vii 7ba752ebb7 docs(changelog): bring the unreleased section up to date 2026-10-09 23:57:41 -05:00
vii 227895faa4 feat(window): use Wayland on NVIDIA Wayland sessions
The X11/XWayland fallback guarded against the explicit-sync disconnect
that is fixed, so the rule and its driver detection are removed.
2026-10-09 23:57:41 -05:00
vii dfc3c8d555 docs(changelog): note the Roblox 2.742 update and new interface fixes 2026-10-09 23:48:55 -05:00
vii e490dff97f feat(runtime): show the desktop side panel menu by default 2026-10-09 23:48:55 -05:00
vii b5b9990017 fix(services): let the BrowserTracker bootstrap use Roblox's storage directory
Tighten a group-writable directory this user owns, reject a storage file
this user cannot vouch for, and treat a stored ID of "0" as unset.
2026-10-09 23:48:55 -05:00
vii d278c0fe0a fix(runtime): run nativeAppBridgeAppStart by default
It starts Roblox's StartupController, which fetches the account's
flag-linked experiments and writes ClientSettings/IxpSettings.json.
2026-10-09 23:48:55 -05:00
vii 044f97e476 fix(update): accept payloads that reorder the leading constructors
Roblox 2.742 moves the process-load constructor behind the JNI singleton
initializer and renames registers in the registry initializer. The deriver
finds each leading constructor by its code, and the loader checks that host
bridges run the checkpoint and JNI singleton constructors and skip the two
wiring entries.
2026-10-09 23:48:55 -05:00
vii 321ba5b3a2 test(flatpak): keep a host flatpak-builder out of the missing-builder check 2026-10-09 14:19:01 -05:00
vii fd364a7bf2 docs: wrap the changelog at 110 columns and drop the glyphs roadmap item
Add a pre-commit check for added changelog lines over 110 characters. Roblox has no Nintendo glyph set, so the roadmap item cannot be met.
2026-10-09 13:50:46 -05:00
vii 5253563e3f fix(appimage): restore a distribution package when its reduced build is too old
A reduced gtk4 older than the one libadwaita requires failed the nightly configure step.
2026-10-09 13:49:38 -05:00
komaruworld 551d2a1829 Probably a vc fix 2026-10-09 15:49:35 +03:00
farwhile 37b88103f4 fix(runtime): stop forcing single-sample MSAA (#185)
* fix(runtime): stop forcing single-sample MSAA

* test removed

* Fix race in webview helper launcher test
2026-10-09 08:33:32 +03:00
vii 763faed0e3 fix(graphics): pick the Vulkan driver that matches the bound kernel driver
Skip a GPU whose kernel driver has no installed Vulkan driver and try the next one. Log why when none can run Vulkan.
2026-10-08 18:41:36 -05:00
komaruworld 74eedada0b improve evilbsd support
Netbsd hi
2026-10-09 00:45:05 +03:00
vii 6cb3dd00a9 fix(compat): bound Bionic strerror_r by errno table and gate atoi shim to non-glibc
strerror_r: Bionic's message table covers errno 0..133 except the
unassigned 41 and 58. Numbers outside that set now get "Unknown error N"
without consulting the host, so the text is the same on glibc and musl and
no longer relies on comparing against strerror_r(-1).

atoi: glibc's atoi is (int)strtol() like Bionic's, so the host symbol is
used there as before. The Bionic shim is registered only on libcs where
that is not true (musl). linker_registry_test expects the matching result.
2026-10-08 16:10:03 -05:00
vii fe2f641a9e chore(merge): merge upstream komaruworld/mocktail main
Brings in 3e6f63e (drop libexecinfo), aa31cdf (Chimera/musl portability),
bc10810 (Bionic atoi, canary signal exit codes) and cb2e9dd (CI fixes).

Conflicts:
- tests/native_update_test.cc: keep both <cerrno> and <csignal>.
- tests/webview_helper_launcher_test.cc: keep the existing python3 socket
  read; it already solves the dash multi-digit fd redirection problem that
  upstream fixed by switching the helper to bash.
2026-10-08 16:10:03 -05:00
komaruworld cb2e9dd598 Small ci fix 2026-10-08 21:12:29 +03:00
komaruworld bc108106db mog chimera 2026-10-08 18:14:23 +03:00
komaruworld aa31cdf395 Improve chimera support 2026-10-08 16:59:11 +03:00
komaruworld 3e6f63ea93 remove libexecinfo dependency 2026-10-08 16:13:41 +03:00
vii bfa6811417 docs(changelog): note the crash and hang fixes 2026-10-07 14:22:39 -05:00
vii 84502c0a1a fix(window): show the hidden window from the main thread
The render thread called SDL_ShowWindow outside the surface commit gate. It now sets a flag, and PumpEvents shows the window inside the gate.
2026-10-07 14:20:16 -05:00
vii 46d1d5ffbe fix(graphics): retry swapchain creation with the game's present mode
A driver can reject a present mode the device did not enable. Retry once with the mode the game asked for, except after errors another mode cannot fix.
2026-10-07 14:20:16 -05:00
vii 545fd8344f fix(session-log): stop waiting for the log writer after 2 seconds
A child that kept the log pipes open made Stop wait forever. Poll for the writer, then SIGKILL it after 2 seconds.
2026-10-07 14:20:16 -05:00
vii 4c40fcdc99 fix(jnivm): stub missing JNI functions and ignore repeat releases
Fill the 65 unimplemented JNI table slots with stubs that log once and return zero, so a call no longer jumps to address 0. A release of a handle with no reference count now returns, so a double release cannot queue one slot twice and give two objects the same handle.
2026-10-07 14:20:16 -05:00
vii d4c7a6f741 fix(app-shell): answer LAUNCH_CONVERSATION so chat requests open 2026-10-07 13:04:31 -05:00
vii 491925f59e test(graphics): register texture_override_test with ctest 2026-10-07 12:01:14 -05:00
vii 2917bc5423 chore(merge): merge upstream komaruworld/mocktail main 2026-10-07 12:01:14 -05:00
komaruworld 2451264696 ETC2 texture fixes
Refs #191
2026-10-07 19:02:35 +03:00
komaruworld a9a96c6861 Add zsync file
fixes #182
2026-10-07 17:35:33 +03:00
vii 5bf4eeb41b fix(window): keep main-thread surface commits out of host present
With explicit sync, a wl_surface commit from the SDL thread can land
between the driver's sync points and its buffer attach. The compositor
answers with a no_buffer protocol error and disconnects the client.
2026-10-06 19:24:27 -05:00
vii d937dd7ea6 fix(flatpak): ask for the X11 socket only as a fallback 2026-10-04 21:08:23 -05:00
vii 169e65bf60 docs(changelog): release 0.3.3 2026-10-04 20:44:33 -05:00
vii 69476f0f47 docs(changelog): add theme crash and Flatpak NVIDIA fixes 2026-10-04 20:14:25 -05:00
vii 79d0cdfb40 chore(merge): merge upstream komaruworld/mocktail main
Keep our captured-pointer handling in place of upstream's relative_mode
version. Keep upstream's motion coalescing guard on our relative flag.
2026-10-04 20:14:13 -05:00
komaruworld 273209b9ea Fix angle platform selection 2026-10-03 21:19:26 +03:00
komaruworld bbfd603e17 Fix captured mouse input
Refs; #135
2026-10-03 14:28:12 +03:00
komaruworld 1ba2941e7f Fix theme crash
Fixes #144
2026-10-03 14:03:25 +03:00
komaruworld 68c90ce428 flatpak nvidia xwayland fallback
#186
2026-10-03 13:48:39 +03:00
komaruworld 9a1ccbb5ce add roblox 3092 support
Also fix openxr shit
2026-10-03 13:40:24 +03:00
vii 068f07e125 chore(merge): merge upstream komaruworld/mocktail main 2026-10-02 20:02:32 -05:00
vii a469689bbb fix(webview): use GTK's software renderer when the helper composites in software
GTK's GL renderer aborted in libepoxy while snapshotting a page switch, which closed the web view. GSK_RENDERER set by the user still wins.
2026-10-02 20:02:32 -05:00
vii 51836a980d docs(changelog): add follow-user, Nix desktop entry, text focus and sky fixes 2026-10-02 20:02:32 -05:00
vii ad546a7781 Merge pull request #16 from kimlherme/docs/recovery-marker-cleanup
docs: document leftover recovery marker cleanup
2026-10-02 19:37:49 -05:00
kimlherme 3c45450dfc docs: document recovery marker cleanup 2026-10-02 20:21:34 -03:00
vii 185c11b57f test(update): expect a missing abi to count only on x86_64 hosts 2026-10-02 12:52:16 -05:00
vii 2c3d7b485e test(update): declare the guest ABI in test catalogs so aarch64 matches
Profiles without abi default to x86_64, so every payload store test skipped its catalog on arm64.
2026-10-02 12:44:47 -05:00
komaruworld 6f8344a28b Sky fixes 2026-10-02 16:52:27 +03:00
vii b60c43e8f0 chore(merge): merge upstream komaruworld/mocktail main
Keep both the CallProtocol and AccountProtocol bridges. Keep our pointer
hold and catalog validation. Re-apply our JNI local-frame counting,
singleton handles, class handle cache, handle-table report and thread
detach release on upstream's split jnivm files.
2026-10-01 21:41:43 -05:00
vii b5e4273277 docs(changelog): add updater, audio and platform fixes 2026-10-01 19:26:21 -05:00
vii d2858fedc8 Merge pull request #13 from kimlherme/fix/payload-update-correctness
fix(update): preserve rollback state and validate payload activation
2026-10-01 19:25:58 -05:00
vii 326a0659ea Merge pull request #15 from kimlherme/fix/platform-lifecycle-compatibility
fix(platform): correct Vulkan teardown, pointer capture and DNS flags
2026-10-01 19:25:51 -05:00
vii dd45467056 Merge pull request #14 from kimlherme/fix/audio-jni-ownership
fix(runtime): preserve OpenSL tickets and JNI array references
2026-10-01 19:25:40 -05:00
vii 94b1036296 docs(changelog): add ETC2 decode speedup 2026-10-01 19:14:37 -05:00
vii 4c61daad0c Merge pull request #10 from glook9001/simd-etc2-decoder
perf(graphics): SIMD PSHUFB ETC2/EAC decode with bit-exact scalar verification
2026-10-01 19:14:33 -05:00
kimlherme f5f224875a fix(platform): correct Vulkan teardown, pointer capture and DNS flags 2026-10-01 21:05:31 -03:00
kimlherme 90d4891a4e fix(update): preserve rollback state and validate payload activation 2026-10-01 21:05:30 -03:00
kimlherme 46dd99266c fix(runtime): preserve OpenSL tickets and JNI array references 2026-10-01 21:05:30 -03:00
77fce18a95 po co chodzic, mozna latac (#183)
* po co chodzic, mozna latac

* Fix jni ownership, split helpers

---------

Co-authored-by: Mia <uwie@thinkwise.ro>
Co-authored-by: komaruworld <rtysus228@gmail.com>
2026-10-01 20:31:52 +03:00
daeeseD ecb80b3ee1 vr: capture the microphone for voice chat on Build 2998 (#181)
* vr: capture the microphone for voice chat on Build 2998

On the vr branch Roblox 2.736.1408 joins voice chat and plays other
players, but never opens a microphone: its FmodAudioDevice capture
methods have no working host path, and main's native capture (537828a
"Fix vc") is pinned to 2.738 RVAs.

Port main's NativeInputCapture and interpose the FmodAudioDevice capture
slots for Build 2998. 2.738 inserted two device-list slots (7 and 11),
so the capture methods sit at {4, 11..15} here instead of {4, 13..17}.
The input-device slots {8, 9, 10, 16}, checked against main's input RVAs
for this image, anchor that layout before anything is patched, and each
capture slot must be a distinct method next to the input-device code.

The voice sink is called through its own vtable after checking that the
vtable and its PCM entry point lie in libroblox, and the retained
shared_ptr is released through the libc++ control-block ABI, so no
further Build 2998 RVAs are needed. If any check fails, capture stays
off and the output routes still work.

MOCKTAIL_NATIVE_INPUT_CAPTURE=0 turns the capture interposition off, and
audio.input_device: disabled reports no microphone to Roblox.

* Requested fix at PR 181
2026-10-01 19:25:48 +03:00
komaruworld d6f0f2c755 Fix some vc
Fokora
2026-10-01 18:03:02 +03:00
glook9001 c6afc5af24 perf(graphics): eliminate crop page faults with strided resampling and streaming WC loads 2026-10-01 12:08:54 +03:00
glook9001 74e7455ee1 perf(graphics): SIMD PSHUFB ETC2/EAC decode with bit-exact scalar verification 2026-10-01 12:08:35 +03:00
vii 000d021452 docs(changelog): add payload import fix 2026-09-30 21:34:06 -05:00
vii 2eef2c3821 Merge pull request #11 from kimlherme/fix/payload-import-rollback
fix(payload): preserve the previous payload on failed imports
2026-09-30 21:33:43 -05:00
Guilhermepycharm 4928443f90 fix(payload): preserve previous files across failed imports
Enable rollback before the first backup move, preserve originals during incomplete backups, and retain recovery files if restoration fails. Add offline full-importer regression coverage and register it with CTest.
2026-09-30 22:54:33 -03:00
native b37497db7b fix text overlay again (#179) 2026-09-30 17:55:30 +03:00
Lupey 6bbdc39d7e feat(nix): add desktop application (#177) 2026-09-30 17:48:05 +03:00
komaruworld 1c11336ba1 Fix text issues
bla bla bla
2026-09-30 16:56:48 +03:00
vii b19bd8406f docs(changelog): release 0.3.2 2026-09-29 23:33:26 -05:00
vii cd46538eb8 docs(readme): document Flatpak build job settings 2026-09-29 23:27:33 -05:00
vii eca5f395eb update(flatpak): default local builds to 2 jobs 2026-09-29 23:25:03 -05:00
vii 26767e925c fix(webview): keep Roblox's WebViewProtocol alive so login challenges finish
Roblox's WebViewProtocol is a counted subsystem that Android's app start
holds for the process lifetime. Nightcap held no handle, so the protocol
and its handleJavascriptCallback subscriber were deleted after each use.
Page messages such as challengeCompleted then waited for the next Lua
WebView call, which came only at the 60 s challenge timeout, and login
failed.

Find the unexported getter through its call site in
initializeAndroidWebViewProtocol, check both functions against x86-64
and AArch64 code contracts, and hold one handle before native init.

The helper no longer closes a solved challenge before the host reads its
events. Roblox closes WebViewProtocol challenges itself, with a 3 s host
fallback; OPEN_CAPTCHA_VIEW windows close once success is forwarded.
Solved-challenge feature names now live in one shared header.
2026-09-29 22:09:27 -05:00
vii 01a0e40463 docs(changelog): add unreleased login, input and ARM64 entries 2026-09-29 18:24:25 -05:00
glook9001 a11e833e06 fix(webview): handle challengeCompleted event to acknowledge callback and close captcha window 2026-09-29 18:21:45 -05:00
vii b9bce36824 docs(roadmap): move first-load stutter to shipped 2026-09-29 17:36:41 -05:00
vii 14c8a55211 docs(github): adapt issue and PR templates for Nightcap 2026-09-29 17:36:41 -05:00
vii 5c5422fa02 fix(login): answer AccountProtocol device-integrity requests
Roblox login challenges asked for a Play Integrity token that nothing
answered, so the challenge sat out LuaApp's native timeout and looped.
A new optional bridge reports integrity as unsupported and answers token
requests at once with an empty token (TOKEN_PROVIDER_UNINITIALIZED).
2026-09-29 16:37:12 -05:00
vii 9b041ec2ac fix(webview): allow 5 s for helper startup and log its timing
Slow starts (cold Flatpak) could exceed the 3 s ready deadline and kill
login verification windows. Launch and timeout logs now report elapsed ms.
2026-09-29 16:05:01 -05:00
komaruworld 6362283ade fix idle 1fps
bob
2026-09-29 19:34:17 +03:00
vii 5f83c4a64f docs(readme): center the logo, title, and badges 2026-09-26 04:07:01 -05:00
vii a9d8e3185e chore(merge): merge upstream komaruworld/mocktail main 2026-09-26 03:59:06 -05:00
vii 67a9f4db97 fix(input): advance the TextBox caret past trailing whitespace (#148)
The final cluster rect SDL_ttf reports stops at the last inked glyph, so a
caret at the end of a single-line field did not move when a trailing space
was typed. Use the laid-out width there; wrapped layouts keep the clamp
because their width is the widest line rather than the last one.
2026-09-25 18:40:23 +03:00
farwhile f07daccc82 fix(input): map middle click to MouseButton3 (#171) 2026-09-25 18:39:24 +03:00
珍惜andHttpAnimations 2eef4f966f Reject partial or overflowing user ids (#168)
Co-authored-by: HttpAnimations <shawndouglasyt@gmail.com>
2026-09-25 18:37:33 +03:00
vii 41003efb09 docs(changelog): record the private server join fix 2026-09-24 23:35:46 -05:00
vii 29a0f03a90 fix(webview): launch web Play buttons without waiting for navigation
Roblox defers a launchGame sent from the WebView until the Lua app next
navigates, so joining a private server stalled until a sidebar tab was
clicked. Parse launchGame in the runtime, queue it through the
experience launch pipeline, and close the WebView. Other commands still
reach Roblox.

Fixes #169
2026-09-24 23:02:48 -05:00
vii 5a27aa0366 fix(runtime): build engine pump rest on aarch64 hosts 2026-09-24 18:01:41 -05:00
vii c99a55dc54 chore(merge): merge upstream komaruworld/mocktail main
Brings in aarch64 host support (#152, #162, #167), natural scrolling (#163)
and the arm64 CI build job. Packaging, Pages and README keep the Nightcap
x86_64 side; upstream's aarch64 bundles are not published.
2026-09-24 17:44:28 -05:00
珍惜andHttpAnimations 30d50f15d1 Fix catalog unable to tell arm64 from x86_64 profiles for the same version. (#167)
Co-authored-by: HttpAnimations <shawndouglasyt@gmail.com>
2026-09-24 20:12:18 +03:00
komaruworld ab940acfb5 add arm64 ci with build
fog, update faq
2026-09-24 19:03:22 +03:00
farwhile 719b1fdd22 fix/respect host natural scrolling direction (#163) 2026-09-23 18:31:41 +03:00
珍惜andHttpAnimations 1fa2e116b3 Fix crashing on x86 hosts (#162)
Co-authored-by: HttpAnimations <shawndouglasyt@gmail.com>
2026-09-23 12:04:53 +03:00
vii 35359f3575 perf(etc2): carve upload staging out of shared blocks
Each ETC2 upload created and destroyed its own staging buffer on the render
thread. Uploads now take ranges of 16 MiB blocks, reused once every range
is released; four empty blocks are kept. Profile traces gain per-frame CPU
time counters and slices for staging and driver memory calls.
2026-09-22 21:31:58 -05:00
珍惜andHttpAnimations fbf97df708 Fix VR crashing is the version is not supported (#158)
Co-authored-by: HttpAnimations <shawndouglasyt@gmail.com>
2026-09-22 23:55:55 +03:00
珍惜andHttpAnimations 40237fae72 arm 64 (#152)
Co-authored-by: HttpAnimations <shawndouglasyt@gmail.com>
2026-09-21 20:09:37 +03:00
vii ad6a3c3147 docs(readme): match the DEB filename that CPack produces 2026-09-20 13:06:45 -05:00
vii d982cd9506 Merge pull request #2 from CoderDayton/release/0.3.1
Release 0.3.1: packaging, input and shutdown fixes
2026-09-20 12:53:44 -05:00
vii 65adb3bddc fix(packaging): fail the release when a package glob matches nothing
An unmatched glob reached sha256sum and gh as literal text. The publish job
now collects the assets into an array and stops if a package is missing.
2026-09-20 12:33:26 -05:00
vii 47e23df2e0 docs(changelog): release 0.3.1 2026-09-20 12:31:39 -05:00
vii c0ff1842ee docs(changelog): record the packaging, input and shutdown fixes 2026-09-20 12:30:00 -05:00
vii e073947b91 fix(window): keep the pointer capture owner alive across shutdown
Cross-thread callers take a strong reference under a mutex, so a shutdown
racing SetGameSessionActive no longer frees the owner mid-call.
2026-09-20 12:26:39 -05:00
vii 66de662e5b fix(input): decide pointer capture from state, not coordinates
The router inferred relative mode from SDL reporting zero absolute
coordinates. X11 does; Wayland reports a position that accumulates the
deltas and leaves the window, so every camera-drag event took the
absolute branch and walked Roblox's cursor to the viewport edge.

Motion, button and wheel events now carry the window's relative mouse
mode, and the RMB camera-drag fallback only captures inside an
experience so the app surface keeps a free cursor.
2026-09-20 12:05:48 -05:00
vii 6c3631058c chore(docs): ignore the working plans directory 2026-09-20 12:05:47 -05:00
vii 6829fd00ed perf(graphics): decode ETC2 uploads off the submitting thread
Uploads decode on a background dispatcher and the submit carries a
timeline semaphore wait, so the GPU waits for the texture rather than
the render thread. The gather of the application's compressed bytes
moves there too when they are already mapped; unmapping or freeing that
memory drains the decodes still reading it.

Staged copies gain an explicit host-write barrier. A queue submission
only makes host writes from before it visible to the device, and these
writes land after the submit.

Devices without timelineSemaphore, and submits whose pNext already sizes
arrays by their semaphore counts, decode inline as before.

Blade Ball, median of three: worst vkQueueSubmit 140ms -> 1ms, p99
4.9ms -> 0.05ms, total 2534ms -> 309ms. Frame interval unchanged; its
tail is the engine's own main-thread step.
2026-09-19 18:02:16 -05:00
vii 8be3618cd5 feat(packaging): ship DEB, RPM and pacman packages with each release
The release workflow now builds the three native packages beside the
AppImage and attaches them to the GitHub release with a checksum each.
Nothing is pushed to a distribution repository.

Packages are named nightcap and take their version from the release tag
instead of upstream's project version, so they no longer claim to be
Mocktail 1.0.4. Each variant conflicts with the others because they all own
/usr/bin/mocktail.

The AUR workflow no longer clones or pushes aur.archlinux.org; it refreshes
nightcap-git metadata on demand and uploads it as an artifact. The recipes
move to packaging/aur/nightcap{,-bin,-git}, point at this repository, and
version from the newest release tag.
2026-09-19 01:12:12 -05:00
vii 265b476440 docs(packaging): list Nightcap releases in the AppStream metadata
The releases block carried upstream's 1.0.x history, so a software centre
read the app as newer than every Nightcap tag and would never offer 0.3.x as
an update. Describe the 0.x line instead, and name Nightcap in the project
description.
2026-09-19 01:11:16 -05:00
vii ff905fe5b7 test(input): pin the caret position before a trailing space
SDL_ttf marks only the zero-length substring past the end of the text with
TTF_SUBSTRING_TEXT_END, so a caret left of a trailing space keeps the cluster
position. Cover both carets so the end-of-text branch cannot swallow the one
beside it.
2026-09-19 01:10:54 -05:00
vii 02108a9bb1 perf(input): reuse the TextBox overlay fonts across rasters
Every keystroke re-ran TTF_Init, a fontconfig sort, six TTF_OpenFont calls
and TTF_Quit on the render thread. Hold the opened fonts on the overlay and
reopen them only when the Roblox font or the point size changes, keeping the
current set when a reopen fails. Measured over 100 rasters, 1.59 ms to
0.12 ms per keystroke.
2026-09-19 00:32:31 -05:00
vii 7dd32335ca fix(input): advance the TextBox caret past trailing whitespace
The final cluster rect SDL_ttf reports stops at the last inked glyph, so a
caret at the end of a single-line field did not move when a trailing space
was typed. Use the laid-out width there; wrapped layouts keep the clamp
because their width is the widest line rather than the last one.
2026-09-19 00:32:31 -05:00
vii 5ed6ed5e84 chore(packaging): declare the libpng dependency
Texture overrides decode PNG through libpng, which stubs/CMakeLists.txt
already requires. Lists it in the Arch, AUR, and Nix packages, the thin
dependency installer, and the README build instructions.
2026-09-18 19:30:58 -05:00
vii cef1b3ec4a Merge upstream komaruworld/mocktail into main 2026-09-18 19:29:24 -05:00
vii 8bde1a1c0d Merge pull request #1 from CoderDayton/release/0.3.0
Release 0.3.0
2026-09-18 19:19:48 -05:00
vii 60e9c5c3ef docs(changelog): release 0.3.0 2026-09-18 19:06:08 -05:00
vii d76e1b6f54 docs(roadmap): move profiling and Discord presence to shipped, add first-load stutter 2026-09-18 19:06:02 -05:00
vii 33c06c063e docs(changelog): note the Sober-style Discord card defaults 2026-09-18 18:54:57 -05:00
vii 9e8afb233a feat(discord-rpc): show the Roblox badge as the default small image
Defaults images.small to the roblox_small asset on the bundled Discord
application, with "Roblox" as its hover text.
2026-09-18 18:54:12 -05:00
vii 7fda1eb239 feat(discord-rpc): show the experience creator and title the card Roblox
Default title is Roblox, details "Playing {place_name}", and state
"by {creator_name}". The creator comes from the games API response
already fetched for the place name. A text field that expands an
empty placeholder is left out, so the state never shows a bare "by".
2026-09-18 18:45:11 -05:00
vii f128e28b83 docs(perf): describe the main-thread pump rest modes and GameMode's governor permission 2026-09-18 18:26:32 -05:00
vii 718e67f9e1 perf(runtime): rest the engine pump between polls instead of spinning
The main thread polls nativeCallMessagesFromMainThread 146k times a second
with nothing to do. Between polls it now rests 100 us: nanosleep when the
governor is performance, TPAUSE (C0.2) when the CPU has WAITPKG, a spin
otherwise. Sleeping under a power-saving governor lets the render thread's
core clock down and drops frames, which is why TPAUSE is the default there.
MOCKTAIL_ENGINE_PUMP_REST=sleep|tpause|spin overrides. The profile trace
gains a slice for pumps that ran a message.
2026-09-18 15:36:54 -05:00
vii c6c354fdb1 perf(graphics): emit ETC2 uploads on the decode pool and stop the trace writer after a failed write
EmitPass reuses the decoder's persistent workers through RunOnDecodeWorkers
instead of creating and joining threads per batch. A failed trace write stops
the writer thread rather than leaving it ticking every 250 ms.
2026-09-18 12:42:36 -05:00
vii 25930d14ff fix(graphics, runtime): honor ETC2 source stride and cut main-loop overhead
ETC2 uploads read bufferRowLength/bufferImageHeight instead of assuming
packed rows. MakeFileObject retains the path field. The main-thread pump
caches its JNI attach, but not the GetJNIEnv fallback, so a VM swap retries.
An idle launch-broker drain skips both locks.
2026-09-18 12:21:29 -05:00
komaruworld 854969457f Fix textures and device info from Nightcap
Ported from CoderDayton/nightcap
2026-09-18 16:55:29 +03:00
vii 65030d8df1 fix(input): hold the pointer position during relative mouse capture
Relative-mode motion carries zero absolute coordinates. The router used
to accumulate the deltas into its pointer position, so a long camera turn
parked the reported position at the viewport edge and Roblox drew its
cursor there when the capture ended. The position now stays where it was
captured while deltas pass through. Zero-coordinate wheel and button
events keep it too, and an unknown position defaults to the viewport
center.
2026-09-18 02:51:03 -05:00
vii 0be5e808c5 fix(runtime): restore the affinity of every thread, not just the caller
sched_setaffinity binds one thread, so the fallback that undid a GameMode pin
only freed the thread that called Start(); the worker threads the fix exists to
protect stayed on the daemon's cores. Walk /proc/self/task instead, and treat a
readdir failure partway through as a failed restore rather than a finished one.

Also stop chrome_trace_writer emitting a valueless "ts" on to_chars failure, or
a bare inf/nan, either of which costs the whole trace instead of one field.
2026-09-18 02:00:58 -05:00
vii 1b030a57eb perf(runtime, graphics): release core-pinning gamemode and parallelize texture uploads
Release the GameMode request when the daemon narrows the CPU mask; the pin
concentrates Roblox's worker pool and is re-applied every few seconds, so
ending the request is the only thing that lifts it. `gamemode: on` keeps it.

Emit texture uploads on worker threads instead of serially inside
vkQueueSubmit, in two passes so mip 0 lands before the levels scaled from it.

Decode ETC2 through a persistent pool and lower the parallel floor to one
128x128 image, so small batches stop running single-threaded.

Document all three in docs/PERFORMANCE.md.
2026-09-18 00:47:57 -05:00
vii 2f5527b8b5 perf(libc_shim): cut path translation cost per file call
Mappings are kept sorted longest prefix first, unmapped paths skip allocation, and the path trace switch is read once.
2026-09-16 21:22:31 -05:00
vii 579cedbf1d perf(textures): pool ETC2 staging buffers and speed up resampling
Staging buffers stay mapped and are reused instead of allocated per upload. Box resampling precomputes column spans and copies repeated rows, with identical output.
2026-09-16 21:22:31 -05:00
vii a7b90e5919 fix(jnivm): stop leaking JNI handles until the table fills
FindClass and GetObjectClass reuse one handle per class, detached threads release their local refs, and local ref deletes and singleton checks no longer scan. A full table logs the classes holding handles. Stale handles above slot 4096 no longer read as C strings.
2026-09-16 21:22:31 -05:00
vii 3bba795ea3 feat(profile): add --profile Chrome trace of Vulkan adapter work
Records presents, GPU waits, submits, ETC2 decode, and pipeline and shader creation for ui.perfetto.dev. Adds a benchmark guide and a trace summary script.
2026-09-16 21:22:30 -05:00
komaruworld 25cfd94c56 BLA BLA BLA 3 2026-09-17 00:07:27 +03:00
vii 5ce4487d4f feat(discord): add custom presence title, state, and icons
text.title sets the activity name, images.* set the large and small icons and hover text, and text.state may be empty to hide it. Text and image fields expand {place_name} and {place_icon}; fields that render empty are omitted. show_place_name: false now also hides the place thumbnail.
2026-09-16 11:45:30 -05:00
vii c544389dd0 fix(textures): make the crosshair arms symmetric
Move the right and bottom arms out 1px so every arm reaches 12px from the center pixel with a 4px gap.
2026-09-16 11:11:06 -05:00
vii bdb84142d7 docs(textures): ship a crosshair override people can copy in
Adds textures/ with the crosshair PNG named by its ETC2 hash and a README
covering install, naming and how to dump hashes for other textures. Nothing
installs or loads it; copying into ~/.config/mocktail/textures is opt-in.
2026-09-16 04:53:53 -05:00
komaruworld 3c20ee3c55 Bla fix main menu bla 2026-09-14 18:26:08 +03:00
UwieandUwie 5e24bee4ab resolution thing or smth idk (#141)
Co-authored-by: Uwie <Uwie>
2026-09-14 17:14:42 +03:00
komaruworld a014751cd4 bla bla bla 2026-09-14 16:38:31 +03:00
komaruworld 7e057232e4 fixes 2026-09-13 20:36:53 +03:00
komaruworld f5080e8a99 fixes 2026-09-13 20:19:00 +03:00
komaruworld 3d89f128ce support alvr and opengl 2026-09-13 19:28:28 +03:00
komaruworld b187efbedf Full support camera
NOT TESTED
2026-09-13 18:20:58 +03:00
komaruworld dd23f06a8b init vr 2026-09-12 21:45:48 +03:00
347 changed files with 55384 additions and 4236 deletions

No files matched your search

+5 -5
View File
@@ -11,14 +11,14 @@ body:
value: |
Please include enough detail for someone else to reproduce the bug.
Use any language you are comfortable with. The maintainer can read Russian and English directly, so either is preferred. If you are more comfortable in another language, use it.
Use any language you are comfortable with.
Please write issues and pull requests yourself. Do not use AI-generated text.
Please write issues and pull requests yourself.
- type: input
id: version
attributes:
label: Mocktail version
label: Nightcap version
description: Provide the release version, package version, or commit hash.
placeholder: "For example: v0.4.0 or 1a2b3c4"
validations:
@@ -37,7 +37,7 @@ body:
id: installation
attributes:
label: Installation method
description: Tell us how Mocktail was installed or built.
description: Tell us how Nightcap was installed or built.
placeholder: "For example: AppImage, Flatpak, AUR, or built from source"
validations:
required: true
@@ -56,7 +56,7 @@ body:
label: Steps to reproduce
description: List the smallest reliable sequence of actions that triggers the problem.
placeholder: |
1. Start Mocktail with ...
1. Start Nightcap with ...
2. Open ...
3. Select ...
4. Observe ...
+5 -4
View File
@@ -1,5 +1,6 @@
# Modified by vii from komaruworld/mocktail. See README "About this fork".
name: Feature request
description: Suggest an improvement or a new capability for Mocktail
description: Suggest an improvement or a new capability for Nightcap
title: "[Feature]: "
labels:
- enhancement
@@ -10,9 +11,9 @@ body:
value: |
Describe what you want to change and why it would be useful.
Use any language you are comfortable with. The maintainer can read Russian and English directly, so either is preferred. If you are more comfortable in another language, use it.
Use any language you are comfortable with.
Please write issues and pull requests yourself. Do not use AI-generated text.
Please write issues and pull requests yourself.
- type: textarea
id: problem
@@ -26,7 +27,7 @@ body:
id: proposal
attributes:
label: Proposed solution
description: Describe how you would like Mocktail to behave.
description: Describe how you would like Nightcap to behave.
validations:
required: true
+2 -1
View File
@@ -1,3 +1,4 @@
<!-- Briefly describe what this pull request adds, changes, or removes. Write it yourself and do not use AI-generated text. Link the issue with "Closes #123" or "Fixes #123". -->
<!-- Modified by vii from komaruworld/mocktail. See README "About this fork". -->
<!-- Briefly describe what this pull request adds, changes, or removes. Link the issue with "Closes #123" or "Fixes #123". -->
Closes #
+119
View File
@@ -0,0 +1,119 @@
name: Publish AUR packages
on:
workflow_dispatch:
inputs:
tag:
description: Release tag to publish, such as v0.3.4
required: true
type: string
workflow_call:
inputs:
tag:
required: true
type: string
secrets:
AUR_SSH_PRIVATE_KEY:
required: false
permissions:
contents: read
concurrency:
group: aur-publish
cancel-in-progress: false
jobs:
publish:
name: Publish nightcap and nightcap-bin
runs-on: ubuntu-24.04
timeout-minutes: 15
environment: release
steps:
- name: Check out the release
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.tag }}
persist-credentials: false
- name: Render the recipes
shell: bash
env:
TAG: ${{ inputs.tag }}
run: |
set -Eeuo pipefail
[[ "$TAG" =~ ^v[0-9]+(\.[0-9]+)*$ ]]
version="${TAG#v}"
archive="nightcap-${version}-1-x86_64.pkg.tar.zst"
# nightcap-bin installs the pacman package from the release page, so
# its checksum is the one published beside that asset.
checksum="$(curl -fsSL \
"https://github.com/${GITHUB_REPOSITORY}/releases/download/${TAG}/${archive}.sha256" |
awk '{print $1}')"
[[ "$checksum" =~ ^[0-9a-f]{64}$ ]]
sed -i "s/^pkgver=.*/pkgver=${version}/" \
packaging/aur/nightcap/PKGBUILD packaging/aur/nightcap-bin/PKGBUILD
sed -i "s/^sha256sums=('SKIP')$/sha256sums=('${checksum}')/" \
packaging/aur/nightcap-bin/PKGBUILD
grep -Fqx "sha256sums=('${checksum}')" packaging/aur/nightcap-bin/PKGBUILD
docker run --rm \
--volume "$GITHUB_WORKSPACE/packaging/aur:/aur" \
ghcr.io/archlinux/archlinux:base-devel@sha256:c3953446016873a756fb7814f10087cac1634c127f7302e80c81c512da796d05 bash -Eeuo pipefail -c '
useradd --create-home builder
for package in nightcap nightcap-bin; do
install -d -o builder -g builder "/tmp/${package}"
install -o builder -g builder -m 0644 \
"/aur/${package}/PKGBUILD" "/tmp/${package}/PKGBUILD"
runuser -u builder -- bash -c \
"cd /tmp/${package} && makepkg --printsrcinfo" \
> "/aur/${package}/.SRCINFO"
done
'
for package in nightcap nightcap-bin; do
grep -Fqx "pkgver=${version}" "packaging/aur/${package}/PKGBUILD"
grep -Fqx $'\tpkgver = '"${version}" "packaging/aur/${package}/.SRCINFO"
done
echo "VERSION=${version}" >> "$GITHUB_ENV"
- name: Push to the AUR
shell: bash
env:
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
run: |
set -Eeuo pipefail
if [[ -z "${AUR_SSH_PRIVATE_KEY}" ]]; then
echo "::notice::AUR_SSH_PRIVATE_KEY is not set; nothing was pushed to the AUR"
exit 0
fi
ssh_dir="$(mktemp -d)"
trap 'rm -rf "$ssh_dir"' EXIT
install -m 0600 /dev/null "$ssh_dir/key"
printf '%s\n' "$AUR_SSH_PRIVATE_KEY" > "$ssh_dir/key"
# The AUR lists this key's fingerprint on its front page:
# SHA256:RFzBCUItH9LZS0cKB5UE6ceAYhBD5C8GeOBip8Z11+4
printf '%s\n' \
'aur.archlinux.org ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEuBKrPzbawxA/k2g6NcyV5jmqwJ2s+zpgZGZ7tpLIcN' \
> "$ssh_dir/known_hosts"
export GIT_SSH_COMMAND="ssh -i $ssh_dir/key -o IdentitiesOnly=yes -o UserKnownHostsFile=$ssh_dir/known_hosts -o StrictHostKeyChecking=yes"
for package in nightcap nightcap-bin; do
checkout="$ssh_dir/$package"
git clone "ssh://aur@aur.archlinux.org/${package}.git" "$checkout"
cp "packaging/aur/${package}/PKGBUILD" "packaging/aur/${package}/.SRCINFO" \
"$checkout/"
git -C "$checkout" add PKGBUILD .SRCINFO
if git -C "$checkout" diff --cached --quiet; then
echo "::notice::${package} is already at ${VERSION}"
continue
fi
git -C "$checkout" \
-c user.name='Nightcap release' \
-c user.email='CoderDayton@users.noreply.github.com' \
commit --quiet -m "Update to ${VERSION}"
# The AUR only accepts the master branch.
git -C "$checkout" push origin HEAD:master
done
+16 -74
View File
@@ -1,8 +1,6 @@
name: Publish AUR packages
name: Check AUR packages
on:
push:
branches: [main]
workflow_dispatch:
permissions:
@@ -13,8 +11,8 @@ concurrency:
cancel-in-progress: true
jobs:
publish:
name: Publish AUR packages
metadata:
name: Refresh AUR metadata
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
@@ -27,10 +25,9 @@ jobs:
shell: bash
run: |
set -Eeuo pipefail
package_dir="$GITHUB_WORKSPACE/packaging/aur/mocktail-git"
version="$(sed -n \
's/^[[:space:]]*VERSION[[:space:]]\+\([0-9][0-9.]*\).*/\1/p' \
CMakeLists.txt | head -n 1)"
package_dir="$GITHUB_WORKSPACE/packaging/aur/nightcap-git"
version="$(git describe --tags --abbrev=0 --match='v[0-9]*')"
version="${version#v}"
[[ "$version" =~ ^[0-9]+(\.[0-9]+)*$ ]]
pkgver="${version}.r$(git rev-list --count HEAD).g$(git rev-parse --short=7 HEAD)"
sed -i "s/^pkgver=.*/pkgver=${pkgver}/" "$package_dir/PKGBUILD"
@@ -39,76 +36,21 @@ jobs:
--volume "$package_dir:/pkg" \
archlinux:base-devel bash -Eeuo pipefail -c '
useradd --create-home builder
install -d -o builder -g builder /tmp/mocktail-git
install -d -o builder -g builder /tmp/nightcap-git
install -o builder -g builder -m 0644 \
/pkg/PKGBUILD /tmp/mocktail-git/PKGBUILD
/pkg/PKGBUILD /tmp/nightcap-git/PKGBUILD
runuser -u builder -- bash -c \
"cd /tmp/mocktail-git && makepkg --printsrcinfo" \
"cd /tmp/nightcap-git && makepkg --printsrcinfo" \
> /pkg/.SRCINFO
'
grep -Fqx "pkgver=${pkgver}" "$package_dir/PKGBUILD"
grep -Fqx $'\tpkgver = '"${pkgver}" "$package_dir/.SRCINFO"
printf 'AUR_PKGVER=%s\n' "$pkgver" >> "$GITHUB_ENV"
- name: Publish to the AUR
shell: bash
env:
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
AUR_SSH_KEY_PASSPHRASE: ${{ secrets.AUR_SSH_KEY_PASSPHRASE }}
run: |
set -Eeuo pipefail
[[ -n "$AUR_SSH_PRIVATE_KEY" ]]
[[ -n "$AUR_SSH_KEY_PASSPHRASE" ]]
private_key="$RUNNER_TEMP/aur_ed25519"
askpass="$RUNNER_TEMP/aur_askpass"
known_hosts="$RUNNER_TEMP/aur_known_hosts"
printf '%s\n' "$AUR_SSH_PRIVATE_KEY" > "$private_key"
unset AUR_SSH_PRIVATE_KEY
chmod 0600 "$private_key"
printf '%s\n' \
'#!/usr/bin/env bash' \
'printf '\''%s\n'\'' "$AUR_SSH_KEY_PASSPHRASE"' \
> "$askpass"
chmod 0700 "$askpass"
eval "$(ssh-agent -s)"
trap 'ssh-agent -k >/dev/null; rm -f -- "$private_key" "$askpass"' EXIT
DISPLAY=:0 SSH_ASKPASS="$askpass" SSH_ASKPASS_REQUIRE=force \
ssh-add "$private_key" </dev/null
unset AUR_SSH_KEY_PASSPHRASE
ssh-keyscan -T 10 -t ed25519 aur.archlinux.org > "$known_hosts"
fingerprint="$(ssh-keygen -lf "$known_hosts" -E sha256 | awk '{print $2}')"
[[ "$fingerprint" == \
'SHA256:RFzBCUItH9LZS0cKB5UE6ceAYhBD5C8GeOBip8Z11+4' ]]
export GIT_SSH_COMMAND="ssh -F /dev/null -i $private_key \
-o BatchMode=yes -o IdentitiesOnly=yes \
-o StrictHostKeyChecking=yes \
-o UserKnownHostsFile=$known_hosts"
publish_aur_package() {
local package_name="$1"
local commit_message="$2"
local source_dir="packaging/aur/$package_name"
local aur_dir="$RUNNER_TEMP/${package_name}-aur"
git clone --branch master --single-branch \
"ssh://aur@aur.archlinux.org/${package_name}.git" "$aur_dir"
install -m 0644 "$source_dir/PKGBUILD" "$aur_dir/PKGBUILD"
install -m 0644 "$source_dir/.SRCINFO" "$aur_dir/.SRCINFO"
git -C "$aur_dir" config user.name komaruworld
git -C "$aur_dir" config user.email rtysus228@gmail.com
git -C "$aur_dir" add PKGBUILD .SRCINFO
if git -C "$aur_dir" diff --cached --quiet; then
printf '%s is already published\n' "$package_name"
return
fi
git -C "$aur_dir" commit -m "$commit_message"
git -C "$aur_dir" push origin HEAD:master
}
publish_aur_package mocktail-git "Update to ${AUR_PKGVER}"
publish_aur_package mocktail 'make builds stable'
publish_aur_package mocktail-bin 'make builds stable'
- name: Upload AUR metadata
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: Nightcap-AUR
path: packaging/aur/nightcap-git/.SRCINFO
if-no-files-found: error
retention-days: 14
+69
View File
@@ -8,22 +8,32 @@ on:
- 'CMakeLists.txt'
- 'Makefile'
- 'README.md'
- '.gitmodules'
- 'cmake/**'
- 'config/**'
- 'include/**'
- 'packaging/**'
- 'scripts/**'
- 'src/**'
- 'stubs/**'
- 'tests/**'
- 'third_party/**'
pull_request:
paths:
- '.github/workflows/**'
- 'CMakeLists.txt'
- 'Makefile'
- 'README.md'
- '.gitmodules'
- 'cmake/**'
- 'config/**'
- 'include/**'
- 'packaging/**'
- 'scripts/**'
- 'src/**'
- 'stubs/**'
- 'tests/**'
- 'third_party/**'
workflow_dispatch:
permissions:
@@ -65,3 +75,62 @@ jobs:
bash tests/flatpak_packaging_test.sh .
bash tests/native_packaging_test.sh .
bash tests/anylinux_dependencies_test.sh .
native-build:
name: Native build (${{ matrix.arch }}, VR ${{ matrix.vr }})
runs-on: ${{ matrix.runner }}
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
include:
- arch: x86_64
runner: ubuntu-26.04
vr: 'ON'
- arch: aarch64
runner: ubuntu-26.04-arm
vr: 'ON'
# Packaging recipes build without the OpenXR submodule; keep the
# non-VR configuration compiling and passing on its own.
- arch: x86_64
runner: ubuntu-26.04
vr: 'OFF'
steps:
- name: Check out source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
submodules: recursive
- name: Install build dependencies
shell: bash
run: |
sudo apt-get update
sudo apt-get install --yes --no-install-recommends \
build-essential binutils cmake git ninja-build pkg-config lld \
libsdl3-dev libsdl3-ttf-dev libcurl4-openssl-dev libssl-dev \
nlohmann-json3-dev libyaml-dev libelf-dev libminizip-dev \
libcapstone-dev libgtk-4-dev libadwaita-1-dev libwebkitgtk-6.0-dev \
libutf8proc-dev libfontconfig1-dev libegl-dev libvulkan-dev \
libplacebo-dev libpng-dev zlib1g-dev \
xvfb xauth libgl1-mesa-dri jq patchelf
- name: Build and check native executables
shell: bash
run: |
test "$(uname -m)" = "${{ matrix.arch }}"
cmake -S . -B build-ci -G Ninja \
-DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=ON \
-DMOCKTAIL_ENABLE_VR=${{ matrix.vr }}
cmake --build build-ci --parallel 4
./build-ci/mocktail --help
./build-ci/mocktail_updater status
- name: Run C++ and integration tests
shell: bash
env:
SDL_AUDIODRIVER: dummy
SDL_VIDEODRIVER: x11
LIBGL_ALWAYS_SOFTWARE: '1'
run: |
xvfb-run -a ctest --test-dir build-ci --output-on-failure \
--parallel 4 --timeout 120
+3
View File
@@ -38,6 +38,9 @@ jobs:
name: Flatpak build
runs-on: ubuntu-24.04
timeout-minutes: 120
# The signing key lives in the release environment, which only main and
# v* tags may use. Builds that do not publish run without it.
environment: ${{ (startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main')) && 'release' || '' }}
env:
# Publish on release tags and on manual runs from main. Pull requests
# only verify that the manifest still builds.
+18 -10
View File
@@ -44,6 +44,8 @@ jobs:
if: needs.probe.outputs.build == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 90
env:
UPINFO: gh-releases-zsync|${{ github.repository_owner }}|${{ github.event.repository.name }}|continuous|Nightcap-nightly-x86_64.AppImage.zsync
steps:
- name: Check out source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -60,6 +62,7 @@ jobs:
--volume "$GITHUB_WORKSPACE:/workspace" \
--workdir /workspace \
--env GH_TOKEN \
--env UPINFO \
ghcr.io/archlinux/archlinux:base-devel@sha256:c3953446016873a756fb7814f10087cac1634c127f7302e80c81c512da796d05 bash -Eeuo pipefail -c '
pacman -Syu --noconfirm --needed \
cmake git ninja pkgconf lld sdl3 sdl3_ttf curl openssl \
@@ -123,11 +126,12 @@ jobs:
export RUNTIME=/tmp/uruntime
export DWARFS_CMD=/tmp/mkdwarfs
make appimage BUILD_DIR=build-appimage JOBS=4 \
APPIMAGE=dist/Mocktail-x86_64.AppImage \
APPIMAGE=dist/Nightcap-nightly-x86_64.AppImage \
PORTABLE_MODE=standalone APPIMAGE_FORMAT=anylinux \
ANYLINUX_PACKAGER=/tmp/quick-sharun \
ANYLINUX_APPIMAGETOOL=/tmp/appimagetool
test -x dist/Mocktail-x86_64.AppImage
test -x dist/Nightcap-nightly-x86_64.AppImage
test -s dist/Nightcap-nightly-x86_64.AppImage.zsync
'
- name: Smoke-test AppImage without FUSE
@@ -140,14 +144,17 @@ jobs:
export XDG_CACHE_HOME="$smoke_dir/cache"
export XDG_STATE_HOME="$smoke_dir/state"
mkdir -p "$XDG_DATA_HOME" "$XDG_CONFIG_HOME" "$XDG_CACHE_HOME" "$XDG_STATE_HOME"
timeout 60s dist/Mocktail-x86_64.AppImage --appimage-extract-and-run --help
timeout 60s dist/Mocktail-x86_64.AppImage --appimage-extract-and-run mocktail_updater status
test "$(dist/Nightcap-nightly-x86_64.AppImage --appimage-updateinformation)" = "$UPINFO"
timeout 60s dist/Nightcap-nightly-x86_64.AppImage --appimage-extract-and-run --help
timeout 60s dist/Nightcap-nightly-x86_64.AppImage --appimage-extract-and-run mocktail_updater status
- name: Upload AppImage
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: Nightcap-AppImage
path: dist/Mocktail-x86_64.AppImage
path: |
dist/Nightcap-nightly-x86_64.AppImage
dist/Nightcap-nightly-x86_64.AppImage.zsync
if-no-files-found: error
retention-days: 14
@@ -173,13 +180,14 @@ jobs:
name: Nightcap-AppImage
path: release
- name: Name and checksum the nightly build
- name: Checksum the nightly build
shell: bash
run: |
set -Eeuo pipefail
# The Makefile builds under upstream's artifact name; the published
# asset carries this fork's.
mv -- release/Mocktail-x86_64.AppImage release/Nightcap-nightly-x86_64.AppImage
# The .zsync file names the AppImage it updates, so the build already
# carries the published name.
test -s release/Nightcap-nightly-x86_64.AppImage
test -s release/Nightcap-nightly-x86_64.AppImage.zsync
(cd release && sha256sum Nightcap-nightly-x86_64.AppImage \
> Nightcap-nightly-x86_64.AppImage.sha256)
find release -maxdepth 1 -type f -print
@@ -206,7 +214,7 @@ jobs:
# Drop assets left by earlier runs under other names.
while IFS= read -r asset; do
case "$asset" in
Nightcap-nightly-x86_64.AppImage|Nightcap-nightly-x86_64.AppImage.sha256)
Nightcap-nightly-x86_64.AppImage|Nightcap-nightly-x86_64.AppImage.zsync|Nightcap-nightly-x86_64.AppImage.sha256)
;;
*)
gh release delete-asset continuous "$asset" --yes
+186 -4
View File
@@ -126,10 +126,153 @@ jobs:
if-no-files-found: error
retention-days: 14
deb:
name: DEB (Ubuntu 26.04)
runs-on: ubuntu-24.04
timeout-minutes: 45
steps:
- name: Check out source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
submodules: recursive
- name: Build DEB
shell: bash
run: |
set -Eeuo pipefail
docker run --rm \
--volume "$GITHUB_WORKSPACE:/workspace" \
--workdir /workspace \
--env MOCKTAIL_PACKAGE_VERSION="$(./scripts/package_version.sh)" \
ubuntu:26.04 bash -Eeuo pipefail -c '
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install --yes --no-install-recommends \
build-essential cmake git ninja-build pkg-config lld fakeroot \
dpkg-dev file libsdl3-dev libsdl3-ttf-dev \
libcurl4-openssl-dev libssl-dev nlohmann-json3-dev \
libyaml-dev libelf-dev libminizip-dev libcapstone-dev \
libgtk-4-dev libadwaita-1-dev libwebkitgtk-6.0-dev \
libutf8proc-dev libfontconfig1-dev libegl-dev \
libvulkan-dev libplacebo-dev zlib1g-dev libpng-dev
cmake -S . -B build-deb -G Ninja \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX=/usr \
-DMOCKTAIL_PACKAGE_NAME=nightcap \
-DMOCKTAIL_PACKAGE_VERSION="$MOCKTAIL_PACKAGE_VERSION" \
-DBUILD_TESTING=OFF
cmake --build build-deb --parallel 4
cpack --config build-deb/CPackConfig.cmake \
-G DEB -B dist/deb
dpkg-deb --info dist/deb/*.deb >/dev/null
'
- name: Upload DEB
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: Nightcap-DEB
path: dist/deb/*.deb
if-no-files-found: error
retention-days: 14
rpm:
name: RPM (Fedora 44)
runs-on: ubuntu-24.04
timeout-minutes: 45
steps:
- name: Check out source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
submodules: recursive
- name: Build RPM
shell: bash
run: |
set -Eeuo pipefail
docker run --rm \
--volume "$GITHUB_WORKSPACE:/workspace" \
--workdir /workspace \
--env MOCKTAIL_PACKAGE_VERSION="$(./scripts/package_version.sh)" \
fedora:44 bash -Eeuo pipefail -c '
dnf install --assumeyes \
gcc-c++ cmake git ninja-build pkgconf-pkg-config lld rpm-build \
SDL3-devel SDL3_ttf-devel libcurl-devel openssl-devel \
nlohmann-json-devel libyaml-devel elfutils-libelf-devel \
minizip-ng-compat-devel capstone-devel gtk4-devel libadwaita-devel \
webkitgtk6.0-devel utf8proc-devel fontconfig-devel \
libglvnd-devel vulkan-headers vulkan-loader-devel \
libplacebo-devel zlib-ng-compat-devel libpng-devel
cmake -S . -B build-rpm -G Ninja \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX=/usr \
-DCMAKE_INSTALL_LIBDIR=lib \
-DMOCKTAIL_PACKAGE_NAME=nightcap \
-DMOCKTAIL_PACKAGE_VERSION="$MOCKTAIL_PACKAGE_VERSION" \
-DBUILD_TESTING=OFF
cmake --build build-rpm --parallel 4
cpack --config build-rpm/CPackConfig.cmake \
-G RPM -B dist/rpm
rpm --query --package --info dist/rpm/*.rpm >/dev/null
'
- name: Upload RPM
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: Nightcap-RPM
path: dist/rpm/*.rpm
if-no-files-found: error
retention-days: 14
arch:
name: Pacman package (Arch Linux)
runs-on: ubuntu-24.04
timeout-minutes: 45
steps:
- name: Check out source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
submodules: recursive
- name: Build pacman package
shell: bash
run: |
set -Eeuo pipefail
version="$(./scripts/package_version.sh)"
sed -i "s/^pkgver=.*/pkgver=${version}/" packaging/arch/PKGBUILD
docker run --rm \
--volume "$GITHUB_WORKSPACE:/workspace" \
--workdir /workspace \
ghcr.io/archlinux/archlinux:base-devel@sha256:c3953446016873a756fb7814f10087cac1634c127f7302e80c81c512da796d05 bash -Eeuo pipefail -c '
pacman -Syu --needed --noconfirm \
cmake git ninja pkgconf lld sdl3 sdl3_ttf curl openssl \
nlohmann-json libyaml libelf minizip capstone gtk4 \
libadwaita webkitgtk-6.0 libutf8proc fontconfig libpng \
libplacebo vulkan-headers vulkan-icd-loader libglvnd zlib
useradd --create-home builder
chown -R builder:builder /workspace
runuser -u builder -- env MOCKTAIL_SOURCE_DIR=/workspace \
makepkg --dir /workspace/packaging/arch \
--noconfirm --cleanbuild --force
install -d dist/arch
cp packaging/arch/*.pkg.tar.zst dist/arch/
pacman -Qp dist/arch/*.pkg.tar.zst >/dev/null
'
- name: Upload pacman package
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: Nightcap-Arch
path: dist/arch/*.pkg.tar.zst
if-no-files-found: error
retention-days: 14
publish:
name: Publish GitHub release
if: startsWith(github.ref, 'refs/tags/v')
needs: [appimage]
needs: [appimage, deb, rpm, arch]
runs-on: ubuntu-24.04
timeout-minutes: 15
permissions:
@@ -138,6 +281,13 @@ jobs:
- name: Check out source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Download packages
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: 'Nightcap-*'
merge-multiple: true
path: release
- name: Download AppImage
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
@@ -152,7 +302,28 @@ jobs:
set -Eeuo pipefail
tag="${GITHUB_REF_NAME}"
mv release/Mocktail-x86_64.AppImage release/Nightcap-x86_64.AppImage
(cd release && sha256sum Nightcap-x86_64.AppImage > Nightcap-x86_64.AppImage.sha256)
# An unmatched glob would otherwise reach sha256sum and gh as its own
# literal text, so every package pattern has to match before upload.
shopt -s nullglob
assets=(Nightcap-x86_64.AppImage)
for glob in '*.deb' '*.rpm' '*.pkg.tar.zst'; do
matched=(release/$glob)
if [[ ${#matched[@]} -eq 0 ]]; then
echo "::error::no ${glob} package among the release artifacts"
exit 1
fi
assets+=("${matched[@]#release/}")
done
uploads=()
for asset in "${assets[@]}"; do
uploads+=("release/$asset" "release/$asset.sha256")
done
(
cd release
for asset in "${assets[@]}"; do
sha256sum "$asset" > "$asset.sha256"
done
)
./scripts/release_notes.sh "$tag" > release/notes.md
# The Flatpak workflow creates the same release; whichever job
# arrives second sees "already exists" and continues.
@@ -164,5 +335,16 @@ jobs:
gh release edit "$tag" --repo "$GITHUB_REPOSITORY" \
--title "Nightcap $tag" --notes-file release/notes.md
gh release upload "$tag" --repo "$GITHUB_REPOSITORY" --clobber \
release/Nightcap-x86_64.AppImage \
release/Nightcap-x86_64.AppImage.sha256
"${uploads[@]}"
# nightcap-bin downloads the pacman package from the release page, so this
# waits for the upload.
aur:
name: Publish AUR packages
if: startsWith(github.ref, 'refs/tags/v')
needs: publish
uses: ./.github/workflows/aur-publish.yml
with:
tag: ${{ github.ref_name }}
secrets:
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
+3
View File
@@ -177,3 +177,6 @@ lsan.log*
# Test fixtures
!/third_party/mcpelauncher-linker/core/libziparchive/**/*.zip
!/third_party/mcpelauncher-linker/core/libziparchive/**/*.apk
# Working plans, not part of the shipped documentation
/docs/plans/
+6
View File
@@ -4,3 +4,9 @@
[submodule "third_party/Vulkan-Headers"]
path = third_party/Vulkan-Headers
url = https://github.com/KhronosGroup/Vulkan-Headers.git
[submodule "third_party/OpenXR-SDK"]
path = third_party/OpenXR-SDK
url = https://github.com/KhronosGroup/OpenXR-SDK.git
[submodule "third_party/smaa"]
path = third_party/smaa
url = https://github.com/iryoku/smaa.git
+286 -44
View File
@@ -1,43 +1,286 @@
# Changelog
All notable changes to Nightcap. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Release notes on
GitHub are taken from the matching section here.
All notable changes to Nightcap. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
Release notes on GitHub are taken from the matching section here.
## [Unreleased]
### Added
- An asset overlay folder, `~/.local/share/mocktail/asset_overlay`. A file there replaces the file Roblox
ships at the same path, such as a cursor, a font or a sky texture, and it stays in place when Roblox
updates. The layout matches Sober's `asset_overlay` folder. `MOCKTAIL_ASSET_OVERLAY_DIR` moves it. Thanks to
@dim-ghub.
- The asset overlay folder now also replaces files Roblox downloads and no longer ships, such as the shift
lock and text cursors. Put the file under `content/`, at the path Roblox uses. Thanks to @dim-ghub for the
idea.
- `MOCKTAIL_ASSET_TRACE=1` now also prints the files Roblox asks for and does not get, as `[asset] miss`.
- Arch Linux: `nightcap-git` is on the AUR and builds the current `main` branch. Thanks to @dim-ghub, who
maintains it.
- `vr.mode` in `config.yaml`: `off` starts on the desktop, `on` starts in VR, and `auto` starts in VR only
when SteamVR is already running with a connected headset. `auto` never starts SteamVR. `vr.enabled` still
works.
- Anti-aliasing in VR. Roblox draws the VR eyes without it, so edges were jagged in the headset. Each eye is
now filtered on its way to the headset. `vr.anti_aliasing` picks `fxaa` (the default), `smaa`, which finds
edges more precisely and keeps text sharper for a little more GPU time, or `off`.
- The log now names each Roblox Java call that Nightcap has no handler for, once per call, as
`[JNI] no handler for <class>.<method><signature>`. This shows what a new Roblox version needs.
- `--profile` traces now show how much GPU memory decoded ETC2 textures take, as the `emulated texture (MB)`
and `compressed texture (MB)` counters. `scripts/summarize_profile_trace.py` prints the peak of each.
### Fixed
- Joining a game from a website link while already in a game no longer freezes Nightcap. It leaves the
current game and joins the new one, and leaving that game returns to the home screen.
### Changed
- The home screen and avatar editor have fewer long frames. PC profiles compile the app's menu scripts to
native code and turn this off while you are in a game, so games run as before. Set
`FFlagDebugLuauCodegenAll` in `fflags.json` to choose it yourself.
- Nightcap answers Roblox's Java calls with less work per call, and the audio thread no longer allocates
memory for each block of sound it plays.
- VR on Vulkan no longer makes the CPU wait for the GPU each frame. The eye copies, the desktop mirror and
the `MOCKTAIL_VR_MIRROR_SHM` readback are ordered on the GPU, so Roblox prepares the next frame while the
last one renders. On a simulated 120 Hz headset this removed 2 to 5 ms of waiting per frame and steadied
the frame rate. The shared-memory mirror now shows each frame one frame later.
- `MOCKTAIL_VR_PERF` also reports `desktop_mirror`, `present_prepare` and `present_interval`.
## [0.3.4] - 2026-10-11
### Added
- The AppImage build now makes a `.zsync` file, so tools like `appimageupdatetool` can update the AppImage.
- The desktop side panel menu now shows by default on PC profiles. Set `FIntFlyoutTestVariantRollout12` in
`fflags.json` to change it.
### Changed
- The Flatpak build asks for the X11 socket only when there is no Wayland session, as before 0.3.3.
- Nightcap now uses Wayland on a Wayland session with NVIDIA drivers too, however it is started. It used to
fall back to X11 there when started from a terminal.
- Building Nightcap no longer needs libexecinfo.
### Fixed
- Fixed Roblox 2.742 being rejected as an update, which left the game on the older version. Nightcap now
accepts Roblox versions that change the order of their start-up steps.
- Fixed Nightcap reporting Mocktail's version number, 1.0.4, in session logs and support bundles. It now
reports its own version.
- Fixed the old Roblox home screen showing instead of the new one with the Me tab and the "For you" and
"Charts" tabs. Nightcap now runs the Roblox start-up step that loads the new interface for your account.
- Fixed anti-aliasing being forced off at every graphics quality level. Roblox now picks it, and a
`FIntDebugForceMSAASamples` value in `fflags.json` still applies. Thanks to @qutad.
- Fixed the voice chat microphone only working on Roblox 2.738. It no longer depends on one Roblox version.
- Fixed other players not hearing you in voice chat after a Roblox update. The updater now rebuilds the
audio device data when the previous version's profile has none.
- Fixed frame hitches while textures load on graphics cards that need ETC2 textures decoded on the CPU.
- Fixed problems on Chimera Linux and other musl systems, including error messages that differed from the
ones Roblox expects.
- Fixed the nightly AppImage build failing when a bundled GTK 4 was older than the one libadwaita needs.
- Fixed a random crash on Wayland with NVIDIA drivers, where the game window closed after you let go of the
camera or left mouse lock. The log showed `wp_linux_drm_syncobj_surface_v1` error 3.
- Fixed some menu actions being ignored. The Chat button on a profile now opens the Party tab with that
player, and clicking a search suggestion now runs the search instead of closing the search menu.
- Fixed possible crashes when the game calls an Android function Nightcap did not provide, or releases the
same reference twice.
- Fixed a possible hang on close when a web view process stayed open. Nightcap now stops waiting for the log
writer after 2 seconds.
- Fixed a possible missing picture on graphics drivers that reject the low-latency present mode. Nightcap now
retries with the game's own mode.
- Fixed a possible Wayland crash when the window first appears on the OpenGL graphics path.
- Fixed a missing picture on machines where the graphics card runs a different driver than the one Nightcap
picked, such as an NVIDIA card on nouveau or a laptop with an Intel chip and an older second card. Nightcap
now picks the Vulkan driver that matches the running kernel driver, and says so when no card can run Vulkan.
## [0.3.3] - 2026-10-04
### Added
- Joining a friend from a Roblox "follow user" link now works. Nightcap looks up which game they are in and
joins it.
- Nix users get a desktop entry for the app.
### Changed
- Roblox textures now decode about 3 times faster, which shortens the stalls when a place loads and when new
parts of the map stream in. Thanks to @glook9001.
### Fixed
- `make update-roblox` no longer deletes your previous Roblox install when an import fails partway through. If
a step fails, the old files are put back, or kept in a folder whose path is printed. Thanks to @kimlherme.
- A failed Roblox update now restores your previous version's records instead of leaving them half switched. A
damaged version file is rejected instead of being half read, and a temporary failure to start the startup
check no longer marks a working Roblox build as broken for good. Thanks to @kimlherme.
- Fixed a possible crash when Roblox clears its audio queue while sound is still playing, and a memory leak in
lists of objects passed to Roblox's Java code. Thanks to @kimlherme.
- Fixed a possible crash when the text overlay shuts down, a freeze when Roblox releases the mouse lock while
another release is already in progress, and network lookups that misread Roblox's options. Thanks to
@kimlherme.
- Fixed the sky sometimes rendering with the wrong textures in the Vulkan renderer.
- Clicking while a Roblox text box has focus no longer closes it. Only Roblox decides when a click ends
typing.
- Going to another page in the web view, such as Friends from a profile, no longer closes the web view. It
crashed on every page change on Wayland systems whose GL setup GTK could not use.
- Fixed a crash when Roblox's saved dark or light theme setting was missing or damaged.
## [0.3.2] - 2026-09-29
### Added
- Performance recordings made with `--profile` now show how busy the CPU was during each frame, which makes it
easier to tell what caused a stutter.
- Experimental support for ARM64 (`aarch64`) Linux, running Roblox's Android `arm64-v8a` client.
### Fixed
- When Roblox asks for a Google Play device check during login, Nightcap now answers right away that it isn't
supported. Before, the "Verifying you're not a bot" screen waited about a minute for an answer and then sent
you back to the login screen.
- Solving a login challenge now closes its window. Before, Roblox's "challenge completed" signal was ignored
and the window stayed open. Thanks to @glook9001.
- Login no longer stalls after you pass a verification check. Before, Roblox only saw the page's "challenge
completed" signal after a one-minute timeout, so it gave up and the login failed. On a Roblox build Nightcap
can't handle this way, the log says "WebViewProtocol getter not recognized" and web pages behave as before.
- Login check windows and other web pages get 5 seconds to start instead of 3, so a slow start no longer
closes them. The log says how long each one took to open.
- Less stutter while joining a game and while textures load in. In Blade Ball, loading hitches dropped by
about half, and the worst one went from 0.8 seconds to 0.55 seconds.
- Clicking Play on a private server now joins right away. Before, nothing happened until you clicked a sidebar
tab like Home.
- Middle click now reaches Roblox as the middle mouse button.
- Scrolling follows your system's natural scrolling setting.
- Fixed a crash on some x86_64 hosts when Roblox asked about a thread other than the one making the call.
- Roblox updates no longer mix up the ARM64 and x86_64 compatibility profiles for the same version.
- A partial or out-of-range user id in `MOCKTAIL_ROBLOX_USER_ID` or `MOCKTAIL_LOCAL_STORAGE_CURRENT_USER_ID`
is now ignored instead of being partly read.
### Changed
- `make flatpak` and `scripts/build_flatpak.sh` now build with 2 jobs by default instead of 4, so a local
Flatpak build is less likely to run out of memory. Set `FLATPAK_JOBS` or `MOCKTAIL_FLATPAK_JOBS` to use
more. Thanks to @glook9001.
## [0.3.1] - 2026-09-20
### Added
- Each release carries a DEB, an RPM, and a pacman package beside the AppImage, with a `.sha256` for every
file. They install `/usr/bin/mocktail` and conflict with each other and with upstream's `mocktail` package.
Nightcap is in no distribution repository or the AUR; the recipes under `packaging/aur/nightcap{,-bin,-git}`
build from this repository locally.
- The AppStream metadata lists the Nightcap releases, so software centres show the release history instead of
upstream's.
### Fixed
- ETC2 texture decoding no longer blocks the thread that submits the frame. Uploads decode on a background
dispatcher and the submit carries a timeline semaphore wait, so the GPU waits for the texture instead of the
render thread. Reading the application's compressed bytes moves to that dispatcher too whenever they are
already mapped. In Blade Ball the worst `vkQueueSubmit` fell from 140ms to 1ms, its p99 from 4.9ms to
0.05ms, and the total time spent in submits from 2534ms to 309ms. Frame interval is unchanged: the remaining
tail is the engine's own main-thread step. A device without the `timelineSemaphore` feature decodes inline
as before, as does a submit whose `pNext` already sizes arrays by its semaphore counts.
- The mouse pointer jumped when a right-click camera drag ended, and drifted to the viewport edge during one.
The input router inferred pointer capture from SDL reporting zero absolute coordinates, which holds on X11
but not on Wayland, where the reported position accumulates the relative deltas and leaves the window.
Motion, button, and wheel events now carry the window's relative mouse mode. The right-click camera fallback
also only captures inside an experience, so the cursor stays free over the app surface.
- Shutting the window down while an experience was starting or leaving could free the pointer capture owner
underneath the thread using it. Callers off the SDL thread now hold a reference for the length of their
call.
- The TextBox caret did not move when a trailing space was typed in a single-line field. The final cluster
rect SDL_ttf reports stops at the last inked glyph, so the laid-out width is used there instead. Wrapped
layouts keep the clamp, because their width is the widest line rather than the last.
- Every TextBox keystroke re-ran `TTF_Init`, a fontconfig sort, six `TTF_OpenFont` calls, and `TTF_Quit` on
the render thread. The overlay holds its opened fonts and reopens them only when the Roblox font or the
point size changes, keeping the current set when a reopen fails. Measured over 100 rasters, 1.59ms to 0.12ms
per keystroke.
- Staged ETC2 copies now carry an explicit host-write barrier. A queue submission only makes host writes from
before it visible to the device, and an asynchronous decode writes after the submit, so without the barrier
those texels were not guaranteed to reach the copy that reads them.
### Changed
- `docs/PERFORMANCE.md` documents the GameMode settings to run with. A `desiredgov=powersave` in
`~/.config/gamemode.ini` overrides the packaged `performance` default and clocks the CPU down for the whole
session while still reporting an active performance request.
## [0.3.0] - 2026-09-18
### Added
- Custom Discord presence. `integrations.discord_rpc.text.title` replaces the name in "Playing Nightcap", and
`integrations.discord_rpc.images` sets the large and small icons and their hover text. Text and image fields
accept `{place_name}`, `{place_icon}`, and `{creator_name}`. A field that expands an empty placeholder is
left out, and `text.state: ""` hides the state line.
- The default Discord card now matches Sober: the title is "Roblox", the details line is "Playing
<experience>", the state is "by <creator>", and the Roblox badge sits inside the experience icon. The
creator comes from the same Roblox games request that already fetches the experience name. With
`show_place_name: false` the creator is hidden too, so the card shows only the title and timer.
- `--profile <file>` writes a Chrome-format trace of the Vulkan adapter's work: presents, GPU waits, submits,
ETC2 texture decode, and pipeline and shader creation. Open it in ui.perfetto.dev. `docs/BENCHMARKING.md`
describes the benchmark runs, and `scripts/summarize_profile_trace.py` turns traces into a before and after
table.
### Fixed
- Texture uploads stalled the frame they were submitted on. ETC2 batches decoded on worker threads but were
then resampled one upload at a time on the submitting thread, which at the default 4x upscale is sixteen
times the decoded texels. Resampling now runs on the same worker pool, in two passes so mip level 0 still
resolves before the mips built from it. In a 70k-part place the worst `vkQueueSubmit` fell from 167ms to
92ms.
- Most ETC2 batches decoded on a single thread. Workers were created and joined per batch, so the parallel
floor had to cover that cost and sat at a 1024x1024 image; batches under it ran on the caller, which was 54%
of all decode time in a 70k-part place. Decode workers are now a persistent pool, and the floor drops to a
128x128 image. Mean decode time in that place fell from 8.2ms to 4.7ms, and 11.8ms before both texture
fixes.
- On CPUs with a wide core-speed spread, Feral GameMode pinned the process to the few fastest cores,
concentrating Roblox's worker pool onto them instead of spreading it. With `performance.gamemode: auto`, a
request that narrows the CPU affinity is now released again, which releases the pin. Set `pin_cores=no` in
`~/.config/gamemode.ini` to keep GameMode without the pinning, or `performance.gamemode: on` to accept it.
See `docs/PERFORMANCE.md`.
- With `show_place_name: false`, Discord no longer shows the experience thumbnail or its name on hover.
- The host main thread no longer spins on the engine's message pump, which cost about half a core and 146,000
empty calls a second. It now rests 100 µs between polls: sleeping when the governor is `performance`,
parking the core with TPAUSE on CPUs that have it, spinning otherwise. With GameMode setting the governor,
process CPU in a 60k-part place fell from 216% to 107% at the same 120 fps. `MOCKTAIL_ENGINE_PUMP_REST`
forces a mode; see `docs/PERFORMANCE.md`.
- ETC2 uploads with a `bufferRowLength` or `bufferImageHeight` larger than the image read the padded rows as
packed data and decoded garbage.
- ETC2 resampling created and joined worker threads on every batch. It now runs on the persistent decode pool.
- A failed trace write left the profile writer thread waking every 250 ms for the rest of the session. It now
exits.
- The `--profile` trace gains a `pump` slice for the engine's main-thread step.
## [0.2.0] - 2026-09-16
### Added
- FAQ covering Discord RPC, log locations, and VR, adapted from upstream.
- `input.raw_mouse` in `config.yaml` sends mouse look deltas in the host's
own units. Turn it on when first-person aiming feels too slow or too fast
under fractional display scaling. Off by default.
- Nightly AppImage builds, published as the `continuous` prerelease. They are
untested; use a tagged release for normal play.
- `input.raw_mouse` in `config.yaml` sends mouse look deltas in the host's own units. Turn it on when
first-person aiming feels too slow or too fast under fractional display scaling. Off by default.
- Nightly AppImage builds, published as the `continuous` prerelease. They are untested; use a tagged release
for normal play.
### Changed
- Values in `fflags.json` now win over the performance preset instead of
stopping the launch. Startup prints each key it kept.
- Discord Rich Presence uses Nightcap's own Discord application, so it shows
as "Playing Nightcap". Terms and privacy pages for it live on the
project site.
- Discord Rich Presence is on by default. Set
`integrations.discord_rpc.enabled: false` to turn it off.
- The AppImage build verifies its reduced dependency packages against digests
taken at build time, rather than a digest list kept in the repository.
- Values in `fflags.json` now win over the performance preset instead of stopping the launch. Startup prints
each key it kept.
- Discord Rich Presence uses Nightcap's own Discord application, so it shows as "Playing Nightcap". Terms and
privacy pages for it live on the project site.
- Discord Rich Presence is on by default. Set `integrations.discord_rpc.enabled: false` to turn it off.
- The AppImage build verifies its reduced dependency packages against digests taken at build time, rather than
a digest list kept in the repository.
### Fixed
- Mouse look keeps responding after a long turn. The tracked pointer position
could drift past the edge of the view, which swallowed every reversal until
the mouse travelled all the way back.
- Nightcap no longer refuses to start with `cannot lock appStorage`. It
created its own credential directory using the process umask, then rejected
that directory for being group writable. Systems with a permissive umask,
such as `002`, failed on first run.
- Mouse look keeps responding after a long turn. The tracked pointer position could drift past the edge of the
view, which swallowed every reversal until the mouse travelled all the way back.
- Nightcap no longer refuses to start with `cannot lock appStorage`. It created its own credential directory
using the process umask, then rejected that directory for being group writable. Systems with a permissive
umask, such as `002`, failed on first run.
## [0.1.0] - 2026-09-15
@@ -45,36 +288,35 @@ First Nightcap release, forked from komaruworld/mocktail.
### Added
- ETC2 and EAC texture emulation on Vulkan, so games that use compressed
mobile textures render correctly on desktop GPUs. Decoding runs on worker
threads to avoid stutter.
- PNG texture overrides in `~/.config/mocktail/textures`, keyed by upload
hash, with a dump mode to find the texture you want to replace.
- Small textures are upscaled 4x by default. Set
`MOCKTAIL_SMALL_TEXTURE_UPSCALE=1` to turn it off.
- AppImage and signed Flatpak builds on every release tag. The Flatpak repo
is published at https://coderdayton.github.io/nightcap/.
- ETC2 and EAC texture emulation on Vulkan, so games that use compressed mobile textures render correctly on
desktop GPUs. Decoding runs on worker threads to avoid stutter.
- PNG texture overrides in `~/.config/mocktail/textures`, keyed by upload hash, with a dump mode to find the
texture you want to replace.
- Small textures are upscaled 4x by default. Set `MOCKTAIL_SMALL_TEXTURE_UPSCALE=1` to turn it off.
- AppImage and signed Flatpak builds on every release tag. The Flatpak repo is published at
https://coderdayton.github.io/nightcap/.
- Roadmap in `docs/ROADMAP.md`.
### Changed
- Frames are presented as soon as they are ready instead of waiting in a
queue, which lowers input latency.
- Roblox now sees the real host RAM and display size instead of a low-end
phone profile.
- App ID is `io.github.CoderDayton.nightcap`, so Nightcap installs next to
upstream Mocktail. The binary and config paths keep the `mocktail` name.
- Frames are presented as soon as they are ready instead of waiting in a queue, which lowers input latency.
- Roblox now sees the real host RAM and display size instead of a low-end phone profile.
- App ID is `io.github.CoderDayton.nightcap`, so Nightcap installs next to upstream Mocktail. The binary and
config paths keep the `mocktail` name.
- New name, icon, and colors.
- The desktop entry launches on Wayland and points at the installed binary.
### Fixed
- Servers no longer kick with error 319 shortly after joining.
- The FMOD output-device bridge is optional, so the updater no longer fails
without it.
- The AppImage build no longer fails on stale checksums for the slimmed
Arch packages.
- The FMOD output-device bridge is optional, so the updater no longer fails without it.
- The AppImage build no longer fails on stale checksums for the slimmed Arch packages.
[Unreleased]: https://github.com/CoderDayton/nightcap/compare/v0.2.0...HEAD
[Unreleased]: https://github.com/CoderDayton/nightcap/compare/v0.3.4...HEAD
[0.3.4]: https://github.com/CoderDayton/nightcap/releases/tag/v0.3.4
[0.3.3]: https://github.com/CoderDayton/nightcap/releases/tag/v0.3.3
[0.3.2]: https://github.com/CoderDayton/nightcap/releases/tag/v0.3.2
[0.3.1]: https://github.com/CoderDayton/nightcap/releases/tag/v0.3.1
[0.3.0]: https://github.com/CoderDayton/nightcap/releases/tag/v0.3.0
[0.2.0]: https://github.com/CoderDayton/nightcap/releases/tag/v0.2.0
[0.1.0]: https://github.com/CoderDayton/nightcap/releases/tag/v0.1.0
+48 -67
View File
@@ -9,8 +9,8 @@
cmake_minimum_required(VERSION 3.20)
project(Mocktail
VERSION 1.0.4
DESCRIPTION "Android x86-64 Roblox compatibility runtime for Linux"
VERSION 0.3.4
DESCRIPTION "Android Roblox compatibility runtime for Linux"
LANGUAGES CXX
)
@@ -126,6 +126,8 @@ set(VULKAN_HEADERS_ENABLE_INSTALL OFF CACHE BOOL
"Install vendored Vulkan-Headers" FORCE)
add_subdirectory(third_party/Vulkan-Headers EXCLUDE_FROM_ALL)
include(${CMAKE_SOURCE_DIR}/cmake/MocktailVR.cmake)
include(CTest)
if(BUILD_TESTING)
# Package builds disable tests and do not fetch Google Test.
@@ -249,6 +251,10 @@ mocktail_apply_compile_options(mocktail_updater)
add_library(mocktail_legacy_jnivm STATIC
src/jnivm/jnivm.cc
src/jnivm/jni_references.cc
src/jnivm/jni_fields.cc
src/jnivm/jni_strings.cc
src/jnivm/jni_method_names.cc
)
add_library(Mocktail::LegacyJni ALIAS mocktail_legacy_jnivm)
target_include_directories(mocktail_legacy_jnivm PUBLIC include)
@@ -266,6 +272,7 @@ add_library(mocktail_runtime STATIC
src/runtime/crash_report_policy.cc
src/runtime/discord_rpc.cc
src/runtime/device_profile.cc
src/runtime/engine_pump_rest.cc
src/runtime/environment.cc
src/runtime/external_launch_broker.cc
src/runtime/frame_rate_policy.cc
@@ -274,6 +281,7 @@ add_library(mocktail_runtime STATIC
src/runtime/game_session_coordinator.cc
src/runtime/graphics_launch_policy.cc
src/runtime/http_client_policy.cc
src/runtime/luau_codegen_switch.cc
src/runtime/memory_limit.cc
src/runtime/owned_pthread.cc
src/runtime/performance_policy.cc
@@ -284,12 +292,18 @@ add_library(mocktail_runtime STATIC
src/runtime/roblox_capability_resolver.cc
src/runtime/roblox_experience_launch_bridge.cc
src/runtime/roblox_launch_uri.cc
src/runtime/roblox_presence_resolver.cc
src/runtime/roblox_game_session_native_adapter.cc
src/runtime/roblox_web_view_bridge.cc
src/runtime/roblox_permissions_bridge.cc
src/runtime/roblox_account_bridge.cc
src/runtime/roblox_message_bus_request_bridge.cc
src/runtime/roblox_call_protocol_bridge.cc
src/runtime/roblox_haptic_protocol_bridge.cc
src/runtime/payload_update_preflight.cc
src/runtime/process_launch_policy.cc
src/runtime/process_diagnostics.cc
src/runtime/asset_overlay_names.cc
src/runtime/runtime_config.cc
src/runtime/runtime_config_bootstrap.cc
src/runtime/runtime_config_file.cc
@@ -384,7 +398,8 @@ add_library(mocktail_webview_helper_policy STATIC
src/webview/webview_helper_policy.cc
)
add_library(Mocktail::WebViewHelperPolicy ALIAS mocktail_webview_helper_policy)
target_include_directories(mocktail_webview_helper_policy PUBLIC src)
target_include_directories(mocktail_webview_helper_policy PUBLIC src
PRIVATE include)
target_link_libraries(mocktail_webview_helper_policy PUBLIC
PkgConfig::WEBKITGTK6
nlohmann_json::nlohmann_json
@@ -471,7 +486,7 @@ target_link_libraries(mocktail_window PUBLIC
Mocktail::SdlVulkanWsi
SDL3::SDL3
${CMAKE_DL_LIBS}
PRIVATE Mocktail::Runtime Mocktail::GlesTextOverlay
PRIVATE Mocktail::Runtime Mocktail::GlesTextOverlay Mocktail::GraphicsFoundation
)
add_library(mocktail_roblox_output_device_runtime STATIC
@@ -552,50 +567,6 @@ target_link_libraries(mocktail_bionic_abi_exports PRIVATE
Threads::Threads
)
include(CheckCXXSourceCompiles)
include(CheckIncludeFileCXX)
include(CMakePushCheckState)
check_include_file_cxx(execinfo.h MOCKTAIL_HAVE_EXECINFO_H)
if(NOT MOCKTAIL_HAVE_EXECINFO_H)
message(FATAL_ERROR
"execinfo.h is required for crash diagnostics; on musl install "
"libexecinfo-devel"
)
endif()
set(MOCKTAIL_EXECINFO_LINK_LIBRARY "")
set(_mocktail_execinfo_link_probe [=[
#include <execinfo.h>
int main() {
void* frames[1] = {};
const int count = backtrace(frames, 1);
return backtrace_symbols(frames, count) == nullptr;
}
]=])
check_cxx_source_compiles(
"${_mocktail_execinfo_link_probe}"
MOCKTAIL_EXECINFO_IN_HOST_LIBC
)
if(NOT MOCKTAIL_EXECINFO_IN_HOST_LIBC)
find_library(MOCKTAIL_EXECINFO_LIBRARY NAMES execinfo REQUIRED)
cmake_push_check_state(RESET)
set(CMAKE_REQUIRED_LIBRARIES "${MOCKTAIL_EXECINFO_LIBRARY}")
check_cxx_source_compiles(
"${_mocktail_execinfo_link_probe}"
MOCKTAIL_EXECINFO_IN_EXTERNAL_LIBRARY
)
cmake_pop_check_state()
if(NOT MOCKTAIL_EXECINFO_IN_EXTERNAL_LIBRARY)
message(FATAL_ERROR
"execinfo.h is available, but backtrace and backtrace_symbols cannot "
"be linked"
)
endif()
set(MOCKTAIL_EXECINFO_LINK_LIBRARY "${MOCKTAIL_EXECINFO_LIBRARY}")
endif()
unset(_mocktail_execinfo_link_probe)
add_library(mocktail_legacy_runtime STATIC
src/legacy/legacy_runtime.cc
src/runtime/roblox_text_input_jni_bridge.cc
@@ -620,7 +591,6 @@ target_link_libraries(mocktail_legacy_runtime PRIVATE
libc_shim
mocktail_window
mocktail_graphics_foundation
${MOCKTAIL_EXECINFO_LINK_LIBRARY}
)
target_compile_definitions(mocktail_legacy_runtime PRIVATE
"MOCKTAIL_DEFAULT_COMPATIBILITY_MANIFEST=\"${MOCKTAIL_DEFAULT_COMPATIBILITY_MANIFEST}\""
@@ -633,6 +603,7 @@ target_compile_definitions(mocktail PRIVATE
"MOCKTAIL_DEFAULT_COMPATIBILITY_MANIFEST=\"${MOCKTAIL_DEFAULT_COMPATIBILITY_MANIFEST}\""
)
target_link_libraries(mocktail PRIVATE
Mocktail::VR
mocktail_legacy_runtime
mocktail_auth_runtime_composition
mocktail_failure_dialog_runtime
@@ -649,6 +620,11 @@ target_link_options(mocktail PRIVATE
)
# Native FreeBSD helper for Linuxulator's unsupported DF socket option.
# The helper is x86-64 assembly; other host architectures have nothing to
# assemble.
if(NOT CMAKE_SYSTEM_PROCESSOR MATCHES "^(x86_64|amd64|AMD64)$")
set(MOCKTAIL_BUILD_FREEBSD_SOCKET_HELPER OFF)
endif()
option(MOCKTAIL_BUILD_FREEBSD_SOCKET_HELPER
"Build the native FreeBSD Linuxulator socket helper" ON)
if(MOCKTAIL_BUILD_FREEBSD_SOCKET_HELPER)
@@ -785,33 +761,39 @@ install(CODE "
endif()
")
set(MOCKTAIL_PACKAGE_NAMES mocktail mocktail-nightly nightcap nightcap-nightly)
set(MOCKTAIL_PACKAGE_NAME "mocktail" CACHE STRING
"Native package name"
)
set(MOCKTAIL_PACKAGE_VERSION "${PROJECT_VERSION}" CACHE STRING
"Native package version"
)
set(MOCKTAIL_PACKAGE_VERSION_SUFFIX "" CACHE STRING
"Native package version suffix"
)
if(NOT MOCKTAIL_PACKAGE_NAME STREQUAL "mocktail" AND
NOT MOCKTAIL_PACKAGE_NAME STREQUAL "mocktail-nightly")
if(NOT MOCKTAIL_PACKAGE_NAME IN_LIST MOCKTAIL_PACKAGE_NAMES)
string(JOIN ", " MOCKTAIL_PACKAGE_NAMES_TEXT ${MOCKTAIL_PACKAGE_NAMES})
message(FATAL_ERROR
"MOCKTAIL_PACKAGE_NAME must be mocktail or mocktail-nightly")
"MOCKTAIL_PACKAGE_NAME must be one of ${MOCKTAIL_PACKAGE_NAMES_TEXT}")
endif()
set(CPACK_PACKAGE_NAME "${MOCKTAIL_PACKAGE_NAME}")
set(CPACK_PACKAGE_VERSION "${PROJECT_VERSION}${MOCKTAIL_PACKAGE_VERSION_SUFFIX}")
set(CPACK_PACKAGE_VENDOR "Mocktail Project")
set(CPACK_PACKAGE_CONTACT "komaruworld <rtysus228@gmail.com>")
set(CPACK_PACKAGE_HOMEPAGE_URL "https://github.com/komaruworld/mocktail")
set(CPACK_PACKAGE_VERSION
"${MOCKTAIL_PACKAGE_VERSION}${MOCKTAIL_PACKAGE_VERSION_SUFFIX}"
)
set(CPACK_PACKAGE_VENDOR "Nightcap")
set(CPACK_PACKAGE_CONTACT "vii <coderdayton14@gmail.com>")
set(CPACK_PACKAGE_HOMEPAGE_URL "https://github.com/CoderDayton/nightcap")
set(CPACK_PACKAGE_DESCRIPTION_SUMMARY "${PROJECT_DESCRIPTION}")
set(CPACK_PACKAGE_DESCRIPTION
"Runs the Android x86-64 Roblox client on Linux."
"Runs the Android Roblox client on Linux."
)
set(CPACK_RESOURCE_FILE_LICENSE "${CMAKE_SOURCE_DIR}/LICENSE")
set(CPACK_PACKAGING_INSTALL_PREFIX "/usr")
set(CPACK_STRIP_FILES TRUE)
set(CPACK_DEBIAN_FILE_NAME DEB-DEFAULT)
set(CPACK_DEBIAN_PACKAGE_MAINTAINER "komaruworld <rtysus228@gmail.com>")
set(CPACK_DEBIAN_PACKAGE_MAINTAINER "vii <coderdayton14@gmail.com>")
set(CPACK_DEBIAN_PACKAGE_SECTION "games")
set(CPACK_DEBIAN_PACKAGE_PRIORITY "optional")
set(CPACK_DEBIAN_PACKAGE_SHLIBDEPS ON)
@@ -821,15 +803,14 @@ set(CPACK_RPM_PACKAGE_LICENSE "Apache-2.0")
set(CPACK_RPM_PACKAGE_GROUP "Amusements/Games")
set(CPACK_RPM_PACKAGE_AUTOREQPROV ON)
if(MOCKTAIL_PACKAGE_NAME STREQUAL "mocktail-nightly")
set(CPACK_DEBIAN_PACKAGE_CONFLICTS "mocktail")
set(CPACK_DEBIAN_PACKAGE_REPLACES "mocktail")
set(CPACK_RPM_PACKAGE_CONFLICTS "mocktail")
else()
set(CPACK_DEBIAN_PACKAGE_CONFLICTS "mocktail-nightly")
set(CPACK_DEBIAN_PACKAGE_REPLACES "mocktail-nightly")
set(CPACK_RPM_PACKAGE_CONFLICTS "mocktail-nightly")
endif()
# Every variant installs the same /usr/bin/mocktail and /usr/share/mocktail,
# so only one of them can be present at a time.
set(MOCKTAIL_PACKAGE_SIBLINGS ${MOCKTAIL_PACKAGE_NAMES})
list(REMOVE_ITEM MOCKTAIL_PACKAGE_SIBLINGS "${MOCKTAIL_PACKAGE_NAME}")
string(JOIN ", " MOCKTAIL_PACKAGE_CONFLICTS ${MOCKTAIL_PACKAGE_SIBLINGS})
set(CPACK_DEBIAN_PACKAGE_CONFLICTS "${MOCKTAIL_PACKAGE_CONFLICTS}")
set(CPACK_DEBIAN_PACKAGE_REPLACES "${MOCKTAIL_PACKAGE_CONFLICTS}")
set(CPACK_RPM_PACKAGE_CONFLICTS "${MOCKTAIL_PACKAGE_CONFLICTS}")
include(CPack)
add_dependencies(mocktail all_stubs)
+20 -6
View File
@@ -42,17 +42,28 @@ attach `latest.log`:
For native installs, a custom `$XDG_STATE_HOME` replaces `~/.local/state`.
## Can I run multiple Roblox instances?
No. Nightcap won't add multi-instance launching. It would mean working around
Roblox's restrictions on running several clients. The [Roblox Terms of Use](https://en.help.roblox.com/hc/en-us/articles/115004647846-Roblox-Terms-of-Use)
prohibit bypassing technical protections, and violating the terms can get your
account suspended.
Bloxstrap brought the option back in [v2.9.0](https://github.com/bloxstraplabs/bloxstrap/releases/tag/v2.9.0)
with a "use at your own risk" warning, then removed it in
[v2.10.0](https://github.com/bloxstraplabs/bloxstrap/releases/tag/v2.10.0)
after Roblox added measures against running multiple clients.
## Can I play in VR?
VR is experimental and lives on upstream's `vr` branch, which does not
include the Nightcap changes. Install the
Yes, as an experiment. VR lives on Nightcap's `vr` branch and is not in the
releases yet. It works on Roblox builds 2998, 3092 and 3222. Install the
[build dependencies](README.md#building), then switch to that branch and
build:
```bash
git remote add upstream https://github.com/komaruworld/mocktail.git
git fetch upstream
git switch -c vr upstream/vr
git fetch origin
git switch vr
git submodule update --init --recursive
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DMOCKTAIL_ENABLE_VR=ON
cmake --build build -j4
@@ -61,5 +72,8 @@ cmake --build build -j4
Start WiVRn or SteamVR/ALVR, connect your headset, then run:
```bash
./build/mocktail -vr
./build/mocktail --vr
```
The [README](README.md#experimental-vr) and [docs/VR.md](docs/VR.md) have the
details, including how to try it without a headset.
+3 -3
View File
@@ -21,7 +21,7 @@ ANYLINUX_APPIMAGETOOL ?= appimagetool
PREFIX ?= /usr
FLATPAK_BUILD_DIR ?= build-flatpak
FLATPAK_MANIFEST ?= packaging/flatpak/io.github.CoderDayton.nightcap.json
FLATPAK_JOBS ?= 4
FLATPAK_JOBS ?= 2
define build_native_runtime
@git submodule update --init --recursive
@@ -130,8 +130,8 @@ auto-run: ## Build/run loop with crash summaries
run-gles: build ## Run strict system EGL/OpenGL ES 3 without Vulkan fallback
@$(BINARY) --graphics opengl
run-angle: ## Run with ANGLE/Vulkan compatibility backend
@MOCKTAIL_GRAPHICS_BACKEND=angle-vulkan ./scripts/run_sober.sh
run-angle: build ## Run with ANGLE/Vulkan compatibility backend
@$(BINARY) --graphics angle-vulkan
update-roblox: ## Validate and import the current x86_64 Roblox bundle cached by Sober
@./scripts/update_roblox_payload.sh
+145 -36
View File
@@ -1,14 +1,22 @@
<!-- Modified by vii from komaruworld/mocktail. See "About this fork" below. -->
# Nightcap
<div align="center">
<img src="https://raw.githubusercontent.com/CoderDayton/nightcap/main/packaging/io.github.CoderDayton.nightcap.svg" alt="Nightcap logo" width="128">
<h1 style="margin-top: 5px;">
Nightcap
<br>
<a href="https://github.com/CoderDayton/nightcap/actions/workflows/ci.yml"><img src="https://github.com/CoderDayton/nightcap/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
<a href="https://github.com/CoderDayton/nightcap/stargazers"><img src="https://img.shields.io/github/stars/CoderDayton/nightcap?style=flat&logo=github" alt="Stars"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache--2.0-blue.svg" alt="License"></a>
<a href="https://discord.gg/DbAUG9hBFU"><img alt="Discord" src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fdiscord.com%2Fapi%2Fv10%2Finvites%2FDbAUG9hBFU%3Fwith_counts%3Dtrue&query=%24.approximate_member_count&label=Discord&logo=discord&logoColor=white&color=5865F2&suffix=%20members"></a>
</h1>
</div>
[![CI](https://github.com/CoderDayton/nightcap/actions/workflows/ci.yml/badge.svg)](https://github.com/CoderDayton/nightcap/actions/workflows/ci.yml)
[![Stars](https://img.shields.io/github/stars/CoderDayton/nightcap?style=flat&logo=github)](https://github.com/CoderDayton/nightcap/stargazers)
[![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE)
Nightcap is Mocktail, tuned for playing on a real PC. It runs the Android
`x86_64` Roblox client on Linux, including a Linux userspace hosted by
FreeBSD's Linuxulator. It provides the Android ABI and JNI pieces the client
expects, then connects them to SDL3 and Vulkan or OpenGL on the Linux side.
Nightcap is Roblox on Linux, tuned for desktop play. It is a Mocktail fork
that keeps up with Roblox updates, with extra bug fixes, custom assets and
more. It runs the Android `x86_64` Roblox client on Linux, including a Linux
userspace hosted by FreeBSD's Linuxulator. It provides the Android ABI and JNI
pieces the client expects, then connects them to SDL3 and Vulkan or OpenGL on
the Linux side.
Nightcap is an independent community project. It is not affiliated with Roblox
Corporation or VinegarHQ and does not distribute the Roblox client.
@@ -21,23 +29,27 @@ with extra fixes for playing on a PC. The program and config paths keep the
`io.github.CoderDayton.nightcap`, so Nightcap and upstream can be installed
side by side.
- **Lower input lag.** Frames are shown as soon as they are ready instead of
waiting in a queue.
- **Sharper textures.** Small textures are upscaled 4x by default, and you
can replace any texture with your own PNG in `~/.config/mocktail/textures`.
Set `MOCKTAIL_SMALL_TEXTURE_UPSCALE=1` to turn the upscale off.
- **ETC2 textures on any GPU.** Games that use compressed mobile textures now
render correctly on desktop GPUs, without stutter.
- **No more error 319 kicks.** Servers no longer drop you shortly after
joining.
- **Correct device info.** Roblox sees your real RAM and screen size.
- **Better desktop launcher.** Runs on Wayland and always finds the installed
binary.
- **Keeps up with Roblox.** New Roblox versions work soon after they come out.
- **Plays more games.** Nightcap answers a server check that some experiences
use, so you can join and stay in those too.
- **Voice chat that keeps working.** Your microphone still works after a
Roblox update.
- **Logging in works.** Verification checks and captchas finish instead of
hanging.
- **The current Roblox look.** The new home screen and side menu, like on
Windows.
- **Your own cursors, fonts and skies.** Drop files in
`~/.local/share/mocktail/asset_overlay` and Nightcap uses them. The layout
matches Sober's `asset_overlay` folder.
- **Smoother mouse and a cooler PC.** Optional raw mouse input, and less CPU
use while idle.
Everything else works the same as upstream. See the
[roadmap](docs/ROADMAP.md) for what is coming next, the
[changelog](CHANGELOG.md) for what each release changed, and the
[FAQ](FAQ.md) for Discord RPC, logs, and VR.
Everything else works the same as upstream. See
[`textures/`](textures/README.md) for custom textures, a ready-made crosshair
and the asset overlay folder, the [roadmap](docs/ROADMAP.md) for what is
coming next, the [changelog](CHANGELOG.md) for what each release changed,
[performance](docs/PERFORMANCE.md) for CPU load, heat and the knobs that change
them, and the [FAQ](FAQ.md) for Discord RPC, logs, and VR.
## Install with Flatpak
@@ -59,10 +71,33 @@ chmod +x Nightcap-x86_64.AppImage
./Nightcap-x86_64.AppImage
```
Or build and install it from source using the steps under
[Building](#building). There are no AUR, DEB, or RPM packages of Nightcap.
Upstream ships those for unmodified Mocktail, see
[komaruworld/mocktail](https://github.com/komaruworld/mocktail#readme).
## Distribution packages
Each release also carries a DEB, an RPM, and a pacman package, with a
`.sha256` beside every file on the
[releases page](https://github.com/CoderDayton/nightcap/releases/latest):
```bash
sudo apt install ./nightcap_<version>_amd64.deb
sudo dnf install ./nightcap-<version>-1.x86_64.rpm
sudo pacman -U nightcap-<version>-1-x86_64.pkg.tar.zst
```
They install `/usr/bin/mocktail`, so they conflict with upstream's `mocktail`
package.
On Arch Linux, [`nightcap-git`](https://aur.archlinux.org/packages/nightcap-git)
is on the AUR. It builds the current `main` branch and is maintained by
[@dim-ghub](https://github.com/dim-ghub):
```bash
yay -S nightcap-git
```
Nightcap is in no other distribution repository.
Or build and install from source using the steps under
[Building](#building).
## How it works
@@ -74,6 +109,12 @@ The APK is checked before any native code is loaded. It is downloaded on first
launch and is not bundled with Nightcap. The last working copy is kept in case
an update fails.
A leftover `.previous_good-recovery.json` in the payload store marks pending
recovery after a publication or restoration failure and blocks further payload
publication and garbage collection. Check `current.json` and `previous_good.json`
against the saved recovery state and fix the underlying problem before manually
removing the marker.
<details>
<summary>Screenshots</summary>
@@ -81,8 +122,6 @@ an update fails.
![Roblox gameplay in Nightcap](assets/screenshots/flatpak-gameplay-tower.png)
![Roblox experience in Nightcap](assets/screenshots/flatpak-gameplay-lobby.png)
</details>
## FFlag overrides
@@ -94,6 +133,14 @@ PC profiles use the legacy Charts page by default to avoid the blank screen
caused by `Color3` errors in the newer SDUI page. An explicit
`FFlagLuaAppChartsAppPage` override takes precedence over this default.
PC profiles also set `FIntFlyoutTestVariantRollout12` to `2`, which shows the
desktop side panel menu. An explicit override takes precedence.
PC profiles compile the app's menus to native code with
`FFlagDebugLuauCodegenAll`, and turn it off while you are in a game. Setting
it yourself turns the switching off and your value applies everywhere. See
[docs/PERFORMANCE.md](docs/PERFORMANCE.md#native-code-in-the-menus).
```json
{
"FFlagExample": "True",
@@ -106,6 +153,8 @@ Edit `~/.config/mocktail/config.yaml`. Useful options:
- `graphics.frame_rate_limit: display` to unlock FPS to your monitor's rate.
- `graphics.vsync: off` for the lowest lag, at the cost of tearing.
- `vr.frame_rate_limit` sets the cap in VR. It defaults to `unlimited`, so the
headset's refresh rate paces frames; use `-1` for Roblox's 60 FPS cap.
- `graphics.backend: opengl` if Vulkan does not work on your GPU.
## Building
@@ -114,8 +163,9 @@ Linux `x86_64` is supported. Experimental FreeBSD 15.1 Linuxulator support has
been tested with an `x86_64` Fedora 44 userspace. On FreeBSD, Nightcap runs
inside Linuxulator; it is not a native FreeBSD binary. Building requires CMake
3.20+, Git, pkg-config, LLD, binutils, a C++17 compiler, SDL 3.4+, SDL3_ttf,
Vulkan, EGL, libplacebo, fontconfig, libcurl, OpenSSL, libelf, libyaml, minizip,
Capstone 5, utf8proc, nlohmann/json, GTK4, libadwaita 1.6+, and WebKitGTK 6.0.
Vulkan, EGL, libplacebo, fontconfig, libcurl, OpenSSL, libelf, libyaml, libpng,
minizip, Capstone 5, utf8proc, nlohmann/json, GTK4, libadwaita 1.6+, and
WebKitGTK 6.0.
See the [FreeBSD Guide](packaging/freebsd/README.md) for setup and launch instructions.
@@ -126,7 +176,7 @@ See the [FreeBSD Guide](packaging/freebsd/README.md) for setup and launch instru
sudo apt update
sudo apt install build-essential cmake git ninja-build pkg-config lld \
libsdl3-dev libsdl3-ttf-dev libcurl4-openssl-dev libssl-dev \
nlohmann-json3-dev libyaml-dev libelf-dev libminizip-dev \
nlohmann-json3-dev libyaml-dev libpng-dev libelf-dev libminizip-dev \
libcapstone-dev libgtk-4-dev libadwaita-1-dev libwebkitgtk-6.0-dev \
libutf8proc-dev libfontconfig1-dev libegl-dev libvulkan-dev \
libplacebo-dev zlib1g-dev
@@ -138,7 +188,7 @@ sudo apt install build-essential cmake git ninja-build pkg-config lld \
```bash
sudo pacman -S --needed base-devel cmake git ninja pkgconf lld sdl3 sdl3_ttf \
curl openssl nlohmann-json libyaml libelf minizip capstone gtk4 \
curl openssl nlohmann-json libyaml libpng libelf minizip capstone gtk4 \
libadwaita webkitgtk-6.0 libutf8proc fontconfig libglvnd \
libplacebo vulkan-headers vulkan-icd-loader zlib
```
@@ -150,7 +200,8 @@ sudo pacman -S --needed base-devel cmake git ninja pkgconf lld sdl3 sdl3_ttf \
```bash
sudo dnf install gcc-c++ cmake git ninja-build pkgconf-pkg-config lld \
SDL3-devel SDL3_ttf-devel libcurl-devel openssl-devel \
nlohmann-json-devel libyaml-devel elfutils-libelf-devel minizip-ng-compat-devel \
nlohmann-json-devel libyaml-devel libpng-devel elfutils-libelf-devel \
minizip-ng-compat-devel \
capstone-devel gtk4-devel libadwaita-devel webkitgtk6.0-devel \
utf8proc-devel fontconfig-devel libglvnd-devel vulkan-headers \
vulkan-loader-devel libplacebo-devel zlib-ng-compat-devel
@@ -174,6 +225,11 @@ Use `sudo make install` instead to install system-wide under `/usr`. To make
Roblox website links open the build tree copy without installing, run
`make register-url-handler`.
To build and install the Flatpak from source, install `flatpak-builder` and run
`make flatpak`. It builds with 2 parallel jobs to keep memory use low. Pass
`FLATPAK_JOBS=N` to `make flatpak`, or set `MOCKTAIL_FLATPAK_JOBS=N` for
`scripts/build_flatpak.sh`, to change that.
To run the same checks as CI before each commit and push, install
[lefthook](https://github.com/evilmartians/lefthook) and run once:
@@ -181,6 +237,59 @@ To run the same checks as CI before each commit and push, install
lefthook install
```
## Experimental VR
Experimental OpenXR VR support on Linux `x86_64` using WiVRn or SteamVR/ALVR.
VR works on these Roblox builds. On any other build, `--vr` stops at start-up
and names them:
| Build | Roblox version |
| --- | --- |
| 2998 | 2.736.1408 |
| 3092 | 2.738.1397 |
| 3222 | 2.742.1059 |
### Build with VR
VR support is compiled in when the OpenXR SDK submodule is checked out
(`MOCKTAIL_ENABLE_VR` defaults to `AUTO`). Without the submodule the build
skips VR. Pass `-DMOCKTAIL_ENABLE_VR=ON` to make a missing submodule an error,
or `OFF` to leave VR out:
```bash
git submodule update --init third_party/OpenXR-SDK
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -j4
```
VR stays off at runtime until you pass `--vr` or set `vr.mode: on` in
`config.yaml`. `vr.mode: auto` starts in VR only when SteamVR is running with a
headset connected.
### Run
Start WiVRn or SteamVR/ALVR and connect your headset first:
```bash
./build/mocktail --vr
```
The OpenXR runtime is detected automatically. To pick one manually:
```bash
MOCKTAIL_VR_RUNTIME=wivrn ./build/mocktail --vr
# or
MOCKTAIL_VR_RUNTIME=alvr MOCKTAIL_GRAPHICS_BACKEND=direct-vulkan ./build/mocktail --vr
```
If you previously set `XR_RUNTIME_JSON`, unset it before switching runtimes.
Use `--no-vr` for desktop mode.
A keyboard opens in the headset when a text box takes focus. The rest is in
[docs/VR.md](docs/VR.md): menus and typing, every VR environment variable,
adding a Roblox build, and testing without a headset.
## Known limitations
Nightcap does not currently work with `hardened_malloc`. Using
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 620 KiB

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1009 KiB

+4
View File
@@ -22,6 +22,7 @@ add_library(mocktail_input_runtime STATIC
${MOCKTAIL_INPUT_ROOT}/src/runtime/roblox_text_font_resolver.cc
${MOCKTAIL_INPUT_ROOT}/src/runtime/roblox_text_surface_overlay.cc
${MOCKTAIL_INPUT_ROOT}/src/runtime/roblox_window_input_runtime.cc
${MOCKTAIL_INPUT_ROOT}/src/runtime/roblox_xr_controller_input.cc
)
add_library(Mocktail::InputRuntime ALIAS mocktail_input_runtime)
target_include_directories(mocktail_input_runtime PUBLIC
@@ -33,12 +34,14 @@ target_include_directories(mocktail_input_runtime PRIVATE
target_link_libraries(mocktail_input_runtime PUBLIC
Mocktail::PlatformSdl
Mocktail::Runtime
Mocktail::VR
mocktail_window
PkgConfig::UTF8PROC
PRIVATE
PkgConfig::FONTCONFIG
SDL3_ttf::SDL3_ttf
nlohmann_json::nlohmann_json
${CMAKE_DL_LIBS}
)
target_compile_features(mocktail_input_runtime PUBLIC cxx_std_17)
mocktail_apply_compile_options(mocktail_input_runtime)
@@ -47,6 +50,7 @@ if(BUILD_TESTING AND TARGET GTest::gtest_main)
add_executable(roblox_input_router_test
${MOCKTAIL_INPUT_ROOT}/tests/roblox_gamepad_input_test.cc
${MOCKTAIL_INPUT_ROOT}/tests/roblox_input_router_test.cc
${MOCKTAIL_INPUT_ROOT}/tests/roblox_xr_controller_input_test.cc
)
target_link_libraries(roblox_input_router_test PRIVATE
Mocktail::InputRuntime
+116
View File
@@ -0,0 +1,116 @@
# Copyright 2026 Mocktail Project Authors
# Licensed under the Apache License, Version 2.0.
include_guard(GLOBAL)
# AUTO builds OpenXR support when the third_party/OpenXR-SDK submodule is
# checked out and skips it otherwise, so source packages that do not fetch
# submodules still build. ON requires the submodule; OFF never builds it.
set(MOCKTAIL_ENABLE_VR "AUTO" CACHE STRING
"Build experimental OpenXR support: AUTO, ON or OFF")
set_property(CACHE MOCKTAIL_ENABLE_VR PROPERTY STRINGS AUTO ON OFF)
set(_mocktail_openxr_sdk "${CMAKE_SOURCE_DIR}/third_party/OpenXR-SDK/CMakeLists.txt")
string(TOUPPER "${MOCKTAIL_ENABLE_VR}" _mocktail_vr_request)
if(_mocktail_vr_request STREQUAL "AUTO")
if(EXISTS "${_mocktail_openxr_sdk}" AND CMAKE_SYSTEM_NAME STREQUAL "Linux")
set(MOCKTAIL_ENABLE_VR ON)
else()
message(STATUS "OpenXR SDK submodule not found; building without VR "
"(git submodule update --init third_party/OpenXR-SDK to enable it)")
set(MOCKTAIL_ENABLE_VR OFF)
endif()
elseif(MOCKTAIL_ENABLE_VR)
set(MOCKTAIL_ENABLE_VR ON)
else()
set(MOCKTAIL_ENABLE_VR OFF)
endif()
add_library(mocktail_vr STATIC
src/vr/diagnostics.cc
src/vr/headset_detection.cc
src/vr/openxr_backend_mode.cc
src/vr/vr_perf.cc
src/vr/vr_mirror.cc
src/vr/xr_controller.cc
src/vr/roblox_vr_device_bridge.cc
src/vr/virtual_keyboard.cc)
add_library(Mocktail::VR ALIAS mocktail_vr)
target_include_directories(mocktail_vr PUBLIC "${CMAKE_SOURCE_DIR}/include")
target_include_directories(mocktail_vr PRIVATE "${CMAKE_SOURCE_DIR}/src")
target_link_libraries(mocktail_vr PUBLIC Vulkan::Headers PRIVATE nlohmann_json::nlohmann_json)
# shm_open for the desktop eye mirror lives in librt on glibc.
find_library(MOCKTAIL_RT_LIBRARY rt)
if(MOCKTAIL_RT_LIBRARY)
target_link_libraries(mocktail_vr PUBLIC ${MOCKTAIL_RT_LIBRARY})
endif()
mocktail_apply_compile_options(mocktail_vr)
if(MOCKTAIL_ENABLE_VR)
if(NOT CMAKE_SYSTEM_NAME STREQUAL "Linux")
message(FATAL_ERROR "Mocktail OpenXR support currently targets Linux")
endif()
if(NOT EXISTS "${_mocktail_openxr_sdk}")
message(FATAL_ERROR
"Initialize OpenXR: git submodule update --init third_party/OpenXR-SDK")
endif()
# Scope upstream's generic option names to this dependency. Static linking
# packages the pinned loader without requiring a matching system loader.
function(mocktail_add_openxr_sdk)
set(BUILD_LOADER ON)
set(DYNAMIC_LOADER OFF)
set(BUILD_WITH_SYSTEM_JSONCPP OFF)
set(BUILD_TESTS OFF)
set(BUILD_API_LAYERS OFF)
set(BUILD_CONFORMANCE_TESTS OFF)
set(BUILD_SDK_TESTS OFF)
set(BUILD_FORCE_GENERATION OFF)
add_subdirectory("${CMAKE_SOURCE_DIR}/third_party/OpenXR-SDK"
"${CMAKE_BINARY_DIR}/third_party/OpenXR-SDK" EXCLUDE_FROM_ALL)
endfunction()
mocktail_add_openxr_sdk()
target_sources(mocktail_vr PRIVATE src/vr/openxr_probe.cc src/vr/openxr_preview.cc
src/vr/openxr_backend.cc src/vr/gles_transport.cc src/vr/xr_actions.cc
src/vr/virtual_keyboard_raster.cc)
# The in-headset keyboard draws its labels with the same SDL3_ttf and
# fontconfig stack as the TextBox overlay.
find_package(SDL3 3.4 REQUIRED CONFIG)
find_package(SDL3_ttf REQUIRED CONFIG)
find_package(PkgConfig REQUIRED)
pkg_check_modules(FONTCONFIG REQUIRED IMPORTED_TARGET fontconfig)
target_link_libraries(mocktail_vr PRIVATE SDL3_ttf::SDL3_ttf SDL3::SDL3
PkgConfig::FONTCONFIG)
# Error unwinding is confined to these native session implementations; their
# public entry points catch errors before returning to the guest runtime.
set_source_files_properties(src/vr/openxr_preview.cc src/vr/openxr_backend.cc
src/vr/xr_actions.cc
PROPERTIES COMPILE_OPTIONS -fexceptions)
target_compile_definitions(mocktail_vr PRIVATE XR_USE_GRAPHICS_API_VULKAN)
option(MOCKTAIL_VR_DIAGNOSTICS
"Compile VR diagnostics: scripted head pose, eye evidence dumps" OFF)
if(MOCKTAIL_VR_DIAGNOSTICS)
target_compile_definitions(mocktail_vr PRIVATE MOCKTAIL_VR_DIAGNOSTICS=1)
endif()
set_source_files_properties(src/vr/openxr_backend.cc PROPERTIES COMPILE_DEFINITIONS "XR_USE_GRAPHICS_API_OPENGL_ES;XR_USE_PLATFORM_EGL")
target_include_directories(mocktail_vr PRIVATE ${MOCKTAIL_EGL_INCLUDE_DIR} ${MOCKTAIL_GLES3_INCLUDE_DIR})
# SMAA's area and search lookup textures for vr.anti_aliasing: smaa.
if(NOT EXISTS "${CMAKE_SOURCE_DIR}/third_party/smaa/Textures/AreaTex.h")
message(FATAL_ERROR
"Initialize SMAA: git submodule update --init third_party/smaa")
endif()
target_include_directories(mocktail_vr PRIVATE "${CMAKE_SOURCE_DIR}/third_party/smaa")
target_link_libraries(mocktail_vr PRIVATE OpenXR::openxr_loader Vulkan::Headers ${CMAKE_DL_LIBS})
else()
target_sources(mocktail_vr PRIVATE src/vr/openxr_disabled.cc
src/vr/xr_actions_disabled.cc)
endif()
if(MOCKTAIL_ENABLE_VR)
add_executable(mocktail_vr_probe src/vr/probe_main.cc)
set_target_properties(mocktail_vr_probe PROPERTIES
OUTPUT_NAME mocktail-vr-probe)
target_link_libraries(mocktail_vr_probe PRIVATE Mocktail::VR)
mocktail_apply_compile_options(mocktail_vr_probe)
install(TARGETS mocktail_vr_probe
RUNTIME DESTINATION "${CMAKE_INSTALL_BINDIR}")
endif()
+36 -2
View File
@@ -52,6 +52,28 @@ graphics:
# unless frame_rate_limit is unlimited.
# vsync: off
# vr:
# String (default: off): experimental VR through OpenXR on supported Roblox
# builds. off always starts on the desktop. on always starts in VR, like
# --vr. auto starts in VR when SteamVR is already running with a connected
# headset (ALVR included), and on the desktop otherwise. auto never starts
# SteamVR. --vr and --no-vr override this for one launch. The older
# enabled: true/false still works as on/off; set one or the other.
# Standalone OpenXR test scene: mocktail-vr-probe --scene.
# mode: off
# String (default: fxaa): smooth jagged edges in the headset. Roblox draws
# the VR eyes without anti-aliasing, so each eye is filtered on its way to
# the headset. fxaa is the cheapest. smaa finds edges more precisely and
# keeps text and fine detail sharper, at a little more GPU time. off sends
# the eyes as rendered.
# anti_aliasing: fxaa
# Optional string or integer: frame-rate limit used instead of
# graphics.frame_rate_limit while VR is active. Same values: -1 (Roblox's
# own cap, usually 60), display, unlimited, or any positive integer. The
# default, unlimited, lets the OpenXR runtime pace frames at the headset's
# refresh rate.
# frame_rate_limit: unlimited
performance:
# Boolean (default: false): size Roblox scheduler/render queues from every
# physical CPU core. A place's Lua/main thread can still remain serial.
@@ -112,12 +134,24 @@ integrations:
public_servers_only: true
# button_label: Join Server
# English defaults. Uncomment only the lines you want to customize.
# Text and image fields accept {place_name}, {place_icon}, and
# {creator_name}. A field that expands an empty placeholder is left out,
# and a "" title shows the app name.
# title, state, and the images fields may be "" to hide them.
# text:
# browsing: Browsing experiences
# joining: Joining an experience
# playing: "{place_name}"
# state: Playing Roblox
# playing: "Playing {place_name}"
# state: "by {creator_name}"
# unknown_place: Unknown experience
# title: Roblox
# Images take an https:// URL, or an asset key uploaded to application_id.
# large: "" hides both images; the small image needs a large one.
# images:
# large: "{place_icon}"
# large_text: "{place_name}"
# small: roblox_small
# small_text: Roblox
# Advanced: overrides the application bundled by the package maintainer.
# application_id: 123456789012345678
+57 -1
View File
@@ -4,6 +4,7 @@
{
"version_name": "2.721.1108",
"version_code": 2350,
"abi": "x86_64",
"elf_build_id": "50e1b0abd123350e794226062fe3a1ef360c5f0d",
"status": "legacy-researched",
"default_allowed": false,
@@ -15,6 +16,7 @@
{
"version_name": "2.725.1142",
"version_code": 2546,
"abi": "x86_64",
"elf_build_id": "d0cb1fa0deb3d9161b4cd77530cbcd2e50de3a21",
"status": "supported",
"default_allowed": true,
@@ -35,6 +37,7 @@
{
"version_name": "2.727.1199",
"version_code": 2628,
"abi": "x86_64",
"elf_build_id": "1686400865ae0e408cd7bd67de7a439625c6fd13",
"status": "supported",
"default_allowed": true,
@@ -46,6 +49,7 @@
{
"version_name": "2.734.917",
"version_code": 2908,
"abi": "x86_64",
"elf_build_id": "63c5109637b7d7b2bdb8ed8f858023ff5ef49326",
"status": "supported",
"default_allowed": true,
@@ -66,6 +70,19 @@
{
"version_name": "2.736.1408",
"version_code": 2998,
"abi": "arm64-v8a",
"elf_build_id": "ed2b035768b1513561d0e6ea7f9bbec35b0e60e5",
"status": "supported",
"default_allowed": true,
"allow_legacy_binary_patches": false,
"allow_host_abi_bridges": true,
"allow_host_constructor_replay": true,
"reason": "AArch64 arm64-v8a payload of the same 2.736.1408 release as the verified x86-64 profile; allocator, constructor replay, and pre-JNI bootstrap RVAs were derived by cross-architecture semantic matching."
},
{
"version_name": "2.736.1408",
"version_code": 2998,
"abi": "x86_64",
"elf_build_id": "ade08266c67aee88ec9c1d00902150e1684dad3a",
"status": "supported",
"default_allowed": true,
@@ -85,7 +102,46 @@
"input_current_method_rva": "0x32d13a6",
"input_select_method_rva": "0x32d0a56"
},
"reason": "Verified 2026-09-06: exact Build-ID profile passed two isolated no-recovery Tier C Vulkan canaries and real game sessions, including fullscreen state synchronization and host FMOD output-device interposition; legacy executable patches and signal recovery remain disabled."
"vr_debug_device_bridge": {
"vtable_rva": "0x6c87b38",
"constructor_rva": "0x3810614",
"state_getter_rva": "0x38109f6",
"eye_getter_rva": "0x38109e6",
"eye_initializer_rva": "0x3810a18",
"emulator_flag_storage_rva": "0x7369920",
"device_create_framebuffer_vtable_offset": "0x180"
},
"reason": "Verified 2026-09-06: exact Build-ID profile passed two isolated no-recovery Tier C Vulkan canaries and real game sessions, including fullscreen state synchronization and host FMOD output-device interposition; legacy executable patches and signal recovery remain disabled. Experimental vr_debug_device_bridge metadata (DebugDeviceVR constructor/state/eye/initializer RVAs) is machine-validated at activation and gated behind --vr; native stereo output is not yet verified end-to-end."
},
{
"allow_host_abi_bridges": true,
"allow_host_constructor_replay": true,
"allow_legacy_binary_patches": false,
"default_allowed": true,
"elf_build_id": "5f0704edd9064f566ee3d6df2bd2fabbcc709f03",
"fmod_output_device_bridge": {
"count_method_rva": "0x320c676",
"current_method_rva": "0x320c6c6",
"info_method_rva": "0x320c716",
"select_method_rva": "0x320bf78",
"string_constructor_rva": "0x1d7a038",
"vtable_layout_version": 2,
"vtable_rva": "0x6cd3ce0"
},
"reason": "Exact-Build-ID host ABI promoted from the validated probation profile; native stereo, VR state/pointer/haptic contracts and FMOD output-device layout v2 were re-derived for 3092. Experimental: runs when selected explicitly (--roblox-lib) but is never an update target, so non-VR users stay on the default payload.",
"status": "experimental",
"user_game_settings_fullscreen_setter_rva": "0x45c5f42",
"version_code": 3092,
"version_name": "2.738.1397",
"vr_debug_device_bridge": {
"vtable_rva": "0x6d08108",
"constructor_rva": "0x37f9fe4",
"state_getter_rva": "0x37fa3c6",
"eye_getter_rva": "0x37fa3b6",
"eye_initializer_rva": "0x37fa3e8",
"emulator_flag_storage_rva": "0x73f4690",
"device_create_framebuffer_vtable_offset": "0x188"
}
}
]
}
+1 -1
View File
@@ -32,7 +32,7 @@
"mode": "vendored-patched-snapshot",
"url": "https://github.com/minecraft-linux/mcpelauncher-linker.git",
"upstream_commit": "1ac3ea6c1cf4f73a84d73f0bff510fba94a7d0f2",
"vendor_tree": "fca44d85e3773c135ccfc603b14abdf6aeb8efd1",
"vendor_tree": "0637221e48f382ec8d0bc701385f4b10bcd07a65",
"patch": "third_party/patches/mcpelauncher-linker.patch",
"nested_sources": {
"android_bionic": {
+129
View File
@@ -0,0 +1,129 @@
# Benchmarking
A pull request that claims a speedup shows before and after numbers taken
the way this guide describes. Numbers from different machines are not
compared with each other. Each run is compared with a run on the same
machine.
## Two layers, two tools
Roblox measures its own work. Nightcap measures the layer between Roblox and
your driver.
| Layer | What it covers | Tool |
| --- | --- | --- |
| Roblox | Scripts, physics, rendering jobs, network | Roblox's stats overlays, Shift+F4 and Shift+F5 in game |
| Nightcap | Vulkan adapter: present, swapchain and GPU waits, submits, ETC2 texture decode, pipeline and shader creation | `--profile` |
A Nightcap change should move the `--profile` numbers. If it only moves the
Roblox overlay numbers, say so in the pull request, since the cause is then
less direct.
## Recording a trace
```sh
mocktail --profile ~/nightcap-before.json
```
The Vulkan adapter writes a Chrome-format trace to that file, a few times a
second. The file is valid JSON after every write, so a session that crashes
or is killed still leaves a readable trace. Exit from the Roblox menu when you are done. Open the file
in [ui.perfetto.dev](https://ui.perfetto.dev) or `chrome://tracing`.
Expect about 130 bytes per event, around 15 MB per minute at 240 FPS.
Profiling is off unless `--profile` is given, and `MOCKTAIL_PROFILE_TRACE`
does the same thing as the flag.
### What the trace contains
Each slice is named after the call it times, on the thread that made it.
| Category | Slices | Arguments |
| --- | --- | --- |
| `present` | `vkQueuePresentKHR`: the whole adapter present, including the text overlay | `result` |
| `present` | `host present`: the host driver's present, nested inside the slice above | |
| `wait` | `vkAcquireNextImageKHR`, `vkAcquireNextImage2KHR`, `vkWaitForFences`, `vkWaitSemaphores`, `vkWaitSemaphoresKHR`, `vkQueueWaitIdle`, `vkDeviceWaitIdle` | |
| `submit` | `vkQueueSubmit`, `vkQueueSubmit2`, `vkQueueSubmit2KHR` | `submits` |
| `texture` | `etc2 decode`: CPU decode of ETC2 textures. On a device with a timeline semaphore this runs on the decode dispatcher, not the submitting thread | `uploads`, `compressed_bytes`, `decoded_bytes` |
| `texture` | `etc2 gather`: the part of an asynchronous upload that stays on the submitting thread. Absent when the decode ran inline | `uploads`, `compressed_bytes`, `decoded_bytes`, `ticket` |
| `texture` | `etc2 staging create`: creating a staging block for ETC2 uploads, when no block has room left | `bytes` |
| `texture` | `etc2 staging destroy`: destroying empty staging blocks beyond the four kept idle | `buffers` |
| `texture` | `etc2 release wait`: a command buffer reset waiting for its uploads' decode. Only waits of 0.1 ms or more are recorded | `ticket` |
| `memory` | `vkAllocateMemory` | `bytes`, `type` (memory type index), `result` |
| `memory` | `vkFreeMemory`, `vkUnmapMemory`: both include waiting for ETC2 decodes that still read that memory | |
| `memory` | `vkMapMemory`, `vkCreateImage`, `vkDestroyImage` | |
| `pipeline` | `vkCreateGraphicsPipelines`, `vkCreateComputePipelines` | `count`, `cache` (1 when a pipeline cache was passed), `result` |
| `pipeline` | `vkCreateShaderModule` | `bytes`, `result` |
| `pipeline` | `vkCreatePipelineCache` | `initial_bytes`, `result` |
| `pump` | `nativeCallMessagesFromMainThread`: the engine's main-thread step, on the host main thread. Only calls of 20 µs or more are recorded; the empty polls between them are not. | `cpu_us`: CPU time the main thread used during the call. Far below the slice's duration means the step spent it waiting |
The `frame interval (ms)` counter is the time between successive adapter
presents. `vkQueuePresentKHR` time minus `host present` time is the adapter's
own cost per frame.
Two more counters cover the same interval. `present thread cpu (ms)` is the
CPU time the thread that presents used in it, and is absent for a frame
presented from a different thread than the one before. `process cpu (ms)` is
the CPU time of every thread together, so dividing it by the frame interval
gives the number of cores busy. A long frame with little present-thread CPU
was spent waiting.
`emulated texture (MB)` is the VRAM held by the decoded images that stand in
for live ETC2/EAC textures, over every mip level and layer, upscaled where
small-texture upscale applies. `compressed texture (MB)` is the size the same
textures would take as ETC2/EAC. Both change only when such an image is
created or destroyed. `summarize_profile_trace.py` prints each one's peak.
## The benchmark runs
Run every scenario before and after the change, on the same build type
(`make build`), with the same `config.yaml`. Close other GPU-heavy apps.
Record `graphics.frame_rate_limit` and `graphics.vsync` in the pull request,
because a frame cap hides frame time gains.
Record `~/.config/gamemode.ini` too, and do not change it between the before
and after runs. `desiredgov` there decides the CPU governor for the whole
session and therefore which engine pump mode is chosen, which moves process
CPU by more than most changes under test. See
[PERFORMANCE.md](PERFORMANCE.md).
Frame interval p99 varies by 2 ms or more between identical runs, so a
difference that size is not a result. Three runs per scenario is the minimum
for any frame-time claim; slice totals for a specific subsystem are far less
noisy and can be read from fewer.
| Scenario | How to start | What to do | What it shows |
| --- | --- | --- | --- |
| Home screen, cold | `mocktail --profile home-cold.json` with an empty shader cache (below) | Wait 30 s on the home screen, exit | First-load stutter: `pipeline` and `texture` slices |
| Tower of Hell | `mocktail --profile toh.json --launch-uri "roblox://experiences/start?placeId=1962086868"` | Stand still at spawn for 60 s, exit | Steady-state frame time on a light scene |
| Murder Mystery 2 | `mocktail --profile mm2.json --launch-uri "roblox://experiences/start?placeId=142823291"` | Stay in the lobby for 60 s, exit | Frame time with many players and a busier scene |
For the cold run, point the driver's shader cache at an empty directory, so
your normal cache is left alone:
```sh
cache=$(mktemp -d)
MESA_SHADER_CACHE_DIR="$cache" __GL_SHADER_DISK_CACHE_PATH="$cache" \
mocktail --profile home-cold.json
```
`MESA_SHADER_CACHE_DIR` applies to Mesa drivers (AMD, Intel) and
`__GL_SHADER_DISK_CACHE_PATH` to the NVIDIA driver.
Run each scenario three times and keep the median run, since one run can
catch a background stall.
## Comparing runs
```sh
scripts/summarize_profile_trace.py toh-before.json toh-after.json
```
This prints a Markdown table with one column per trace: frame count, frame
interval mean, p50, p95, p99 and max, then count, total, mean, p99 and max
for every slice name, largest total first. Paste the table into the pull
request and attach both traces.
For frame time, compare p95 and p99 as well as the mean. Stutter shows up in
the tail first. For first-load work, compare the `total (ms)` rows of the
`pipeline` and `texture` slices from the cold run.
+258
View File
@@ -0,0 +1,258 @@
# Performance and CPU load
What Nightcap does to your CPU and GPU, which knobs change it, and how to
measure rather than guess.
## GameMode core pinning
Nightcap asks Feral GameMode for a performance session at startup
(`performance.gamemode`, default `auto`). GameMode raises the CPU governor and
the process priority, which helps.
On a CPU with a wide spread of per-core maximum frequencies — Intel P-core and
E-core parts, AMD parts with preferred cores — GameMode also pins the process
to the fastest cores. On a 32-thread host that can mean 4 usable threads out of
32. Roblox spreads its work across a worker pool, so the pin concentrates that
pool onto a couple of physical cores instead of spreading it. Those cores sit
near their boost ceiling and run hot while the rest of the chip idles.
GameMode re-applies the pin roughly every 5 seconds, so widening the mask from
inside the process does not hold. Releasing the request does release the pin.
Under `auto`, Nightcap therefore checks its CPU affinity immediately after the
request. If GameMode narrowed it, Nightcap ends the request and prints:
```
[gamemode] released: it pinned this process to a subset of the CPUs, ...
```
The trade is that GameMode's governor and priority boost go with it. To keep
those and lose only the pinning, turn pinning off for every game on the host.
These are the settings to run with:
```ini
# ~/.config/gamemode.ini
[general]
desiredgov=performance
[cpu]
pin_cores=no
park_cores=no
```
`desiredgov` is the one to check first. The packaged default in
`/usr/share/gamemode/gamemode.ini` is already `performance`, but a value in
your own file replaces it, and `desiredgov=powersave` there means GameMode
clocks the CPU **down** for the whole session while still reporting
`[gamemode] performance request active`. The symptoms are a low CPU clock
under load, a GPU that never leaves its lower SM clocks, and the engine pump
falling back to `tpause` — which costs about 70% of a core instead of 3%,
because `sleep` is only chosen when the governor reads `performance`. On one
13900K host, correcting `powersave` to `performance` took the process from
177% CPU to 42% and the package from 70°C to 53-63°C, at the same frame rate.
GameMode reads `$XDG_CONFIG_HOME`, `/usr/share/gamemode` and `/etc`, in that
order. It does not read anything under Nightcap's own config directory, so this
file has to live in one of those three places. The daemon reads it when it
starts, so restart it after editing: `systemctl --user restart gamemoded`.
Restarting the daemon drops the session of any game already running; relaunch
the game for new settings to apply to it.
`gamemoded` is D-Bus activated through
`com.feralinteractive.GameMode.service`, so `systemctl --user stop gamemoded`
does not hold — the next client request starts it again within seconds. To
measure with GameMode genuinely off, mask it:
```bash
systemctl --user mask gamemoded # and --unmask afterwards
```
The governor switch runs through polkit. Debian and Ubuntu only allow it for
members of the `gamemode` group; without that, the daemon's journal shows
`cpugovctl set performance: Not authorized` and the governor stays put while
the session is otherwise active. Add yourself and log in again:
```bash
sudo usermod -aG gamemode "$USER"
```
Fedora and Arch allow any logged-in local user.
Setting `performance.gamemode: on` accepts the pinning instead: `on` is an
explicit request for GameMode, and Nightcap keeps the session whatever it does
to the affinity. `off` never contacts the daemon.
To see which CPUs the process may use:
```bash
pid=$(pgrep -x Main)
for t in /proc/$pid/task/*; do taskset -pc "${t##*/}"; done
```
Two traps here. The engine process reports its name as `Main`, not
`mocktail`; `pgrep -x mocktail` finds only the launcher, which sits near 0%
CPU and tells you nothing. And `taskset -p` reads one thread, not the whole
process, so a single call reports the main thread's mask and says nothing
about the workers — which is where the game's load actually is.
For the same reason, `ps -L -o psr=` shows which core each thread is running
on right now, not which cores it is allowed on. On a hybrid CPU the scheduler
preferring P-cores for runnable work is normal and is not a mask.
## The main-thread pump
Roblox posts its per-frame main-thread step at a time of its own choosing,
and nothing signals the host when it does. The host main thread therefore
polls `nativeCallMessagesFromMainThread`, and a post that waits more than a
fraction of a millisecond costs a whole frame. Between polls the thread rests
for 100 µs, and how it rests decides the CPU clock:
| Mode | When | What it costs |
| --- | --- | --- |
| `sleep` | The governor is `performance`, which GameMode sets | The thread idles at about 3% of a core. The governor holds the clock. |
| `tpause` | Otherwise, on a CPU with WAITPKG (Intel 12th gen and newer) | The core stays awake in a light C0 state. Frame rate holds; the OS books the time as busy, so the thread shows around 70% of a core, at lower power than a spin. |
| `spin` | Otherwise | The core stays awake at full power. |
Under a power-saving governor the CPU clocks a bursty core down unless some
core stays awake, and Roblox's render thread then runs slower and drops
frames. That is why `sleep` is not the default there. Startup prints the
chosen mode:
```
[main] engine pump rest: tpause (MOCKTAIL_ENGINE_PUMP_REST=sleep|tpause|spin)
```
`MOCKTAIL_ENGINE_PUMP_REST` forces a mode. `sleep` without the governor's help
runs cooler for a few frames per second less; measure before keeping it.
## Small texture upscaling
`MOCKTAIL_SMALL_TEXTURE_UPSCALE` (default `4`) stores ETC2 textures of 64px or
smaller at N times their width and height. At the default that is 16 times the
pixels per affected texture.
Upscaling alone adds no detail. Each original pixel becomes an N×N block of the
same colour, and the GPU's filtering already blends a small texture that much
when it draws it. The extra size only shows when a replacement PNG in
`~/.config/mocktail/textures` has more detail than the original.
The cost lands in two places. Decoding and resampling is CPU work on up to 8
threads while textures load, which shows up as a burst rather than a steady
load. The enlarged textures then stay in GPU memory for the session and cost
extra sampling bandwidth on every frame that uses them. In Blade Ball the
memory share is small: decoded textures came to 5.1 times their ETC2 size both
with the default and with upscaling off. The `emulated texture (MB)` and
`compressed texture (MB)` counters in a `--profile` trace give the figures for
any game.
That decode does not run on the thread that submits the frame. On a device
with a timeline semaphore, uploads decode on a background dispatcher and the
submit carries a semaphore wait, so the GPU waits for the texture instead of
the render thread. In the trace this is the `etc2 decode` slice on its own
thread, with only a negligible `etc2 gather` left on the submitting one. A
device without that feature decodes inline as before.
Set it to `1` to turn upscaling off:
```bash
MOCKTAIL_SMALL_TEXTURE_UPSCALE=1 mocktail
```
## Native code in the menus
The home screen and avatar editor run Roblox's Lua app in the Luau interpreter,
and its long frames come from that thread. PC profiles set
`FFlagDebugLuauCodegenAll`, which compiles every script to native code. That
makes the menus' Lua about 2.5 times cheaper for about 56 MB of compiled code.
In a game the same flag compiles every game script too: Blade Ball grew from 7
MB to about 300 MB and hit Roblox's native code limit, which shows warnings in
F9. So Nightcap turns the flag off when a game starts joining and back on when
you return to the menus. Roblox reads the flag each time it compiles a script,
and a game's compiled code is freed when you leave it. Games run with Roblox's
own per-script choice, the same as without the flag.
The log shows each switch:
```text
[luau-codegen] menus-only switch at +0x<offset in libroblox.so>
[luau-codegen] native code for all scripts off
[luau-codegen] native code for all scripts on
```
Setting `FFlagDebugLuauCodegenAll` in `fflags.json` turns the switching off
and your value applies everywhere. To keep the interpreter in the menus too:
```json
{
"FFlagDebugLuauCodegenAll": false
}
```
## Frame rate and present mode
`graphics.frame_rate_limit` (default `-1`) leaves Roblox's own in-game
framerate cap in charge; Nightcap sets no `DFIntTaskSchedulerTargetFps`
override. A fixed value forwards that number to the scheduler.
In VR, `vr.frame_rate_limit` (`MOCKTAIL_VR_FRAME_RATE_LIMIT`) replaces
`graphics.frame_rate_limit` and takes the same values. Its default is
`unlimited`: the scheduler target goes to 240 and `xrWaitFrame` holds each
frame to the headset's refresh rate. Set `-1` to restore Roblox's own cap, or a
number such as `90` to pin it.
`MOCKTAIL_VR_RENDER_SCALE` (default `1.0`, range `0.25`–`2.0`) scales each
axis of the OpenXR runtime's recommended eye size. SteamVR's recommendation
already includes its own supersampling, so on a Quest 2 at 90 Hz it is about
2244x2352 per eye. If a frame misses the 11.1 ms budget, the runtime halves
the rate to 45 FPS; `0.75` renders about 56% of the pixels.
`MOCKTAIL_VR_UNPACED=1` is a benchmark mode. Roblox no longer waits for the
headset between frames; each XR frame stays open across several Roblox frames
and is submitted with the newest eyes once per display period. Pair it with a
fixed scheduler target above 240 and `MOCKTAIL_VSYNC=off` to find the
maximum rate:
```bash
MOCKTAIL_VR_UNPACED=1 MOCKTAIL_VR_FRAME_RATE_LIMIT=9999 MOCKTAIL_VSYNC=off mocktail --vr
```
Frames beyond the headset refresh are never displayed, and they all reuse the
open frame's head pose, so expect judder. Do not play in this mode.
`graphics.vsync` (default `auto`) selects the presentation mode. `auto` and
`on` both pick the lowest-latency synchronized mode the driver offers, in
order: `FIFO_LATEST_READY`, `MAILBOX`, `FIFO_RELAXED`, `FIFO`. The first two do
not block the render thread on the display refresh, so the frame rate is
governed by the in-game cap rather than by presentation. `off` selects
`IMMEDIATE` and does not synchronize at all.
If the frame rate is higher than your refresh rate, the extra frames are work
you cannot see. Lower the in-game cap to your refresh rate first.
## Measuring
Shift+F4 in game shows Roblox's own scheduler jobs, with a millisecond cost and
a percentage for each. It does not show Nightcap's threads — the Vulkan
adapter, the ETC2 decode workers, the libc shim — so it is a reading on the
game, not on the process.
For the whole process:
```bash
top -H -p $(pgrep -x mocktail) # per-thread CPU, sorted
ps -L -o psr=,pcpu=,comm= -p $(pgrep -x mocktail) # which core each thread is on
nvidia-smi --query-gpu=utilization.gpu,temperature.gpu,power.draw --format=csv
```
A thread pinned near 100% while the frame rate is steady is a busy-wait. The
one exception is the host main thread in `tpause` mode, which the OS reports
as busy while the core is parked (see above). Load spread across many
`RBX Worker` threads is the game's own scheduler, and its cost tracks the
place you are in — part count, moving parts and joints all show in the
Shift+F4 World section.
`--profile <file>` writes a Chrome trace of the Vulkan adapter's work for
ui.perfetto.dev. See [BENCHMARKING.md](BENCHMARKING.md).
+28 -56
View File
@@ -15,81 +15,53 @@ Goal: a small GTK4 settings window, reachable from the app menu and from a
- Graphics tab: backend, frame rate limit, vsync, small texture upscale.
- Input tab: the `input.raw_mouse` toggle and controller options.
- Advanced tab: FFlag overrides with a plain text editor for `fflags.json`.
- Discord tab: the `discord_rpc` block (see item 3).
- Discord tab: every field in the `discord_rpc` block, so a friend can see
what you are playing and you can switch it all off in one click.
- Changes apply on next launch. A restart button restarts Nightcap.
Done when every option in `config/mocktail.example.yaml` that a player would
touch can be set without a text editor.
## 2. Controller button glyphs
Upstream added Xbox and PlayStation gamepad support through SDL. Controllers
already hot-plug and release their held inputs when unplugged. One rough edge
is left.
- Correct button glyphs for the connected pad. `SDL_GetGamepadType` is only
read for Xbox and PlayStation pads, so a Switch Pro, a GameCube pad, or any
generic pad SDL reports as `SDL_GAMEPAD_TYPE_STANDARD` reaches Roblox as
type 0 and draws generic glyphs. `SDL_GetRealGamepadType`, and the vendor
and product IDs, are available and unused.
Done when a plugged-in pad shows its own buttons.
## 3. Discord Rich Presence
Upstream ships Discord RPC. Nightcap turns it on by default; see the
[FAQ](../FAQ.md) to switch it off. It shows the experience name, elapsed
time, the experience icon, and a join button when the server is public.
- Custom presence: set the title, details line, state line, and large and
small icons from `config.yaml`, with placeholders like the experience name
and elapsed time.
- A Discord tab in the settings window to toggle each field.
Done when a friend can see what you are playing and join in one click, and
you can switch it all off in one click.
## 4. Performance profiling
Every performance change should come with numbers. Today they come with a
feeling.
- A frame time overlay, toggled by a key and a config flag, showing CPU time,
GPU time, and present latency.
- Shader compile and pipeline cache tracing, so first-load stutter can be
measured and cut.
- A `--profile` flag that writes a Chrome-format trace file, readable in
`ui.perfetto.dev` and `chrome://tracing`.
- A short benchmark guide in `docs/` with a fixed set of experiences, so
before and after numbers can be compared across machines.
Done when a pull request that claims a speedup can show the trace that
proves it.
## Shipped
- Lower input latency: frames are presented as soon as they are ready.
- Controllers hot-plug without a restart, and a pad that is unplugged mid-game
releases its held buttons and sticks instead of leaving them stuck.
releases its held buttons and sticks instead of leaving them stuck.
- Mouse look keeps responding after a long turn, instead of going dead until
the pointer travels back from past the screen edge.
the pointer travels back from past the screen edge.
- Optional raw mouse input: `input.raw_mouse` in `config.yaml` sends look
deltas in host units, so fractional display scaling no longer changes
first-person sensitivity.
deltas in host units, so fractional display scaling no longer changes
first-person sensitivity.
- Small textures upscaled 4x by default, with PNG overrides in
`~/.config/mocktail/textures`.
`~/.config/mocktail/textures`.
- ETC2 textures decoded on desktop GPUs without stutter.
- No more error 319 kicks after joining a server.
- Roblox sees the real host RAM and screen size.
- Desktop launcher that works on Wayland and finds the installed binary.
- Discord Rich Presence on by default.
- Discord Rich Presence on by default, with the experience name, creator,
elapsed time, the experience icon with a Roblox badge, and a join button
when the server is public. See the [FAQ](../FAQ.md) to switch it off.
- Custom Discord presence: title, details, state, and large and small icons
from `config.yaml`, with `{place_name}`, `{place_icon}`, and
`{creator_name}` placeholders.
- `--profile` writes a Chrome-format trace of the Vulkan adapter's work,
including shader and pipeline creation, readable in `ui.perfetto.dev`.
[BENCHMARKING.md](BENCHMARKING.md) fixes the experiences to measure and
`scripts/summarize_profile_trace.py` builds the before and after table.
- Texture uploads no longer stall the frame they land on: ETC2 decode and
resampling run on a persistent worker pool.
- Less stutter while joining an experience and while textures load in. See
the [changelog](../CHANGELOG.md) for the numbers.
- The host main thread rests between engine polls instead of spinning, and
GameMode's core pinning is released, so Roblox's workers spread across
every core. See [PERFORMANCE.md](PERFORMANCE.md).
- Own app ID `io.github.CoderDayton.nightcap`, so Nightcap installs next to
upstream.
upstream.
- AppImage and signed Flatpak releases on every `v*` tag, with the Flatpak
repo on GitHub Pages.
repo on GitHub Pages.
## Not planned
- Replacing the `mocktail` binary and config names. Keeping them makes
upstream merges clean.
- DEB, RPM, and AUR packages. Upstream ships those for unmodified Mocktail.
upstream merges clean.
+135
View File
@@ -0,0 +1,135 @@
# VR
Experimental OpenXR VR is opt-in: launch with `--vr`, or set `vr.mode` in
`config.yaml`:
- `off` (default) always starts on the desktop.
- `on` always starts in VR.
- `auto` starts in VR when SteamVR is already running with a connected
headset, ALVR included, and on the desktop otherwise. It never starts
SteamVR, so start SteamVR and connect the headset first.
`--vr` and `--no-vr` override `vr.mode` for one launch. The older
`vr.enabled: true`/`false` still works as `on`/`off`. Everything here only
applies while VR is active. The [README](../README.md#experimental-vr) covers
building and running.
## Menus and typing
The home menu uses Roblox's `More` button. The desktop side panel is turned
off in VR, because the VR pointer cannot click it.
When a text box takes focus (chat, search), a keyboard opens in front of you,
below eye level. It is a full US layout without the function row or
Ctrl/Alt. Point at a key and pull the trigger. While you point at the keyboard,
the trigger types and does not click the game behind it. Hold Backspace to
delete repeatedly. Shift applies to the next key and selects its upper symbol;
Caps Lock stays on until you tap it again. Close dismisses the keyboard and
leaves the text box. Set `MOCKTAIL_VR_KEYBOARD=0` to turn the keyboard off.
## Environment variables
## Runtime and display
| Variable | Default | Effect |
| --- | --- | --- |
| `MOCKTAIL_VR_ENABLED` | `0` | Same as `--vr` (`1`) or `--no-vr` (`0`). |
| `MOCKTAIL_VR_RUNTIME` | `auto` | OpenXR runtime: `auto`, `system`, `wivrn`, `steamvr` or `alvr`. `XR_RUNTIME_JSON` overrides it. |
| `MOCKTAIL_VR_BACKEND` | `xr` | `native` keeps Roblox's stereo rendering without an OpenXR runtime, for debugging. |
| `MOCKTAIL_VR_FRAME_RATE_LIMIT` | `unlimited` | Frame-rate cap in VR, same values as `graphics.frame_rate_limit` (also `vr.frame_rate_limit`). With `unlimited` the headset's refresh rate paces frames. |
| `MOCKTAIL_VR_ANTI_ALIASING` | `fxaa` | Same as `vr.anti_aliasing`. Roblox draws the VR eyes without anti-aliasing, so each eye is filtered on its way to the headset (Vulkan only). `fxaa` is the cheapest; `smaa` finds edges more precisely and keeps text sharper; `off` sends them as rendered. |
| `MOCKTAIL_VR_RENDER_SCALE` | `1.0` | Scales each axis of the runtime's recommended eye size, `0.25` to `2.0`. |
| `MOCKTAIL_VR_UNPACED` | `0` | `1` stops waiting for the headset between frames, to measure the maximum frame rate. Frames past the headset refresh are not shown. Not for playing. |
| `MOCKTAIL_VR_DESKTOP_MIRROR` | `1` | `0` stops copying the left eye to the desktop window. |
| `MOCKTAIL_VR_MIRROR_SHM` | unset | A shared-memory name such as `/nightcap-mirror` publishes both eyes for an external viewer. The segment is private to your user. |
| `MOCKTAIL_VR_KEYBOARD` | `1` | `0` turns off the in-headset keyboard. |
## Hands and scale
| Variable | Default | Effect |
| --- | --- | --- |
| `MOCKTAIL_VR_HAND_POSE_SOURCE` | `aim` | `grip` orients hands from the controller grip pose instead of the aim pose. |
| `MOCKTAIL_VR_HAND_PITCH_OFFSET` | `-20` | Degrees added to Roblox's own hand pitch. |
| `MOCKTAIL_VR_HAND_PITCH` | unset | Replaces the total hand pitch, in degrees. |
| `MOCKTAIL_VR_WORLD_SCALE` | `1.0` | Scales head, hand and eye offsets. `MOCKTAIL_VR_EYE_OFFSET_SCALE` is an older name for it. |
## Performance tracing
| Variable | Default | Effect |
| --- | --- | --- |
| `MOCKTAIL_VR_PERF` | unset | `1` logs per-stage frame timings and counters once a second. `present_interval` is the frame time; `desktop_mirror`, `copy_record` and `copy_fence_wait` show time the CPU spent on VR copies. |
| `MOCKTAIL_VR_MEASURE_START_NS`, `MOCKTAIL_VR_MEASURE_END_NS` | unset | Limit `MOCKTAIL_VR_PERF` to a `CLOCK_MONOTONIC` window. |
| `MOCKTAIL_VR_POSE_LOG_INTERVAL` | `240` | Frames between head-pose log lines. |
## Diagnostics builds
These only work in builds configured with `-DMOCKTAIL_VR_DIAGNOSTICS=ON`.
Release builds ignore them.
| Variable | Default | Effect |
| --- | --- | --- |
| `MOCKTAIL_VR_POSE_SOURCE` | `runtime` | `script` drives the head from a scripted motion instead of the headset, for testing without wearing it. |
| `MOCKTAIL_VR_POSE_AMPLITUDE_POSITION`, `_YAW`, `_PITCH` | `0.25`, `0.5`, `0.17` | Scripted motion size, in metres and radians. |
| `MOCKTAIL_VR_POSE_PERIOD_POSITION`, `_ROTATION` | `240`, `360` | Scripted motion period, in frames. |
| `MOCKTAIL_VR_XR_EVIDENCE_DIR` | unset | Writes submitted eye images to this directory. |
| `MOCKTAIL_VR_XR_EVIDENCE_INTERVAL` | `20` | Frames between evidence images. |
## Adding a Roblox build
VR needs addresses that are specific to each Roblox build. To use a supported
build that is not the active one, point `--roblox-lib` at its `libroblox.so`.
To add a build, run the offset finder on its library:
```bash
python3 scripts/find_roblox_vr_offsets.py --elf <libroblox.so> --output offsets.json
```
It needs the `capstone` Python package. Add its values to `kContracts` in
`src/vr/roblox_vr_device_bridge.cc`. Nightcap checks those addresses against
Roblox's code before it hooks anything, and leaves VR off if they do not
match. To run the same checks on real libraries, set
`MOCKTAIL_TEST_VR_PAYLOADS` to a folder of `<build>-<build id>/libroblox.so`
payloads and run `ctest -R VrSupportedBuilds`.
For a second opinion, solve the new build from the previous one and compare
the two answers. It needs the previous build's HostAbi profile and
compatibility manifest:
```bash
python3 scripts/roblox_abi_check.py --reference-lib <old libroblox.so> \
--reference-profile <old profile.json> \
--reference-compatibility config/roblox_compatibility.json \
--candidate-lib <new libroblox.so> --vr
```
It lists every field where the finder and the solver disagree and exits 0
when they agree on all of them.
## Testing without a headset
[Monado](https://monado.freedesktop.org) can stand in for a headset. Its
`qwerty` driver gives a head and two controllers that you move with the
keyboard and mouse:
```bash
QWERTY_ENABLE=1 XRT_DEBUG_GUI=1 monado-service
```
Then, in another terminal:
```bash
XR_RUNTIME_JSON=/usr/share/openxr/1/openxr_monado.json ./build/mocktail --vr
```
The keys work while Monado's debug window has focus. The simulated display is
small, so text in the headset view looks coarse.
Two tests need an OpenXR runtime and skip without one. To run them, start
Monado with no window and opt in:
```bash
XRT_COMPOSITOR_NULL=1 monado-service &
XR_RUNTIME_JSON=/usr/share/openxr/1/openxr_monado.json MOCKTAIL_TEST_XR_GLES=1 \
ctest --test-dir build -R GlesVrTransportTest.OpenXr
```
+11
View File
@@ -49,6 +49,17 @@
gdb
];
desktopItems = [
(pkgs.makeDesktopItem {
name = "Mocktail";
exec = "mocktail";
icon = "mocktail";
desktopName = "Mocktail";
comment = "Experimental Roblox compatibility runtime for Linux and FreeBSD";
categories = [ "Game" ];
})
];
shellHook = ''
export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:${pkgs.lib.makeLibraryPath package.buildInputs}"
+30
View File
@@ -33,6 +33,23 @@ void SetLegacyBionicDiagnosticsEnabled(bool enabled);
// guest layout explicit instead of exposing the incompatible host libc type.
using MocktailBionicMutexAttr = int64_t;
// Bionic LP64 pthread_attr_t: 56 bytes with explicit field offsets. The host
// glibc layout is opaque and wider on aarch64, so guest buffers must be read
// and written through this struct only.
struct MocktailBionicPthreadAttr {
std::uint32_t flags;
void* stack_base;
std::size_t stack_size;
std::size_t guard_size;
std::int32_t sched_policy;
std::int32_t sched_priority;
char reserved[16];
};
static_assert(sizeof(MocktailBionicPthreadAttr) == 56);
inline constexpr std::uint32_t kMocktailBionicAttrFlagDetached = 0x1;
inline constexpr std::uint32_t kMocktailBionicAttrFlagUserStack = 0x2;
extern "C" {
void mocktail_set_current_jni_env(void* env);
@@ -84,6 +101,19 @@ int mocktail_pthread_barrier_init(pthread_barrier_t* barrier,
int mocktail_pthread_barrier_destroy(pthread_barrier_t* barrier);
int mocktail_pthread_barrier_wait(pthread_barrier_t* barrier);
int mocktail_pthread_attr_init(MocktailBionicPthreadAttr* attr);
int mocktail_pthread_attr_destroy(MocktailBionicPthreadAttr* attr);
int mocktail_pthread_attr_setstacksize(MocktailBionicPthreadAttr* attr,
size_t stack_size);
int mocktail_pthread_attr_setdetachstate(MocktailBionicPthreadAttr* attr,
int detach_state);
int mocktail_pthread_attr_setschedparam(MocktailBionicPthreadAttr* attr,
const struct sched_param* parameters);
int mocktail_pthread_getattr_np(pthread_t thread,
MocktailBionicPthreadAttr* attr);
int mocktail_pthread_attr_getstack(const MocktailBionicPthreadAttr* attr,
void** stack_base, size_t* stack_size);
extern void* mocktail_gameactivity_on_start_native;
extern void* mocktail_gameactivity_on_resume_native;
extern void* mocktail_gameactivity_on_surface_created_native;
+19 -10
View File
@@ -11,19 +11,27 @@
namespace mocktail::compat {
// glibc's atoi is (int)strtol(), exactly like Bionic's, so the host symbol is
// used as-is there. Other libcs (musl) differ on overflow and need the shim.
#ifdef __GLIBC__
inline constexpr bool kHostAtoiMatchesBionic = true;
#else
inline constexpr bool kHostAtoiMatchesBionic = false;
#endif
// Android uses size_t fields here; glibc uses int and musl has no equivalent.
// An all-zero fallback means unknown, not zero usage.
struct BionicMallinfoSnapshot {
size_t arena = 0;
size_t ordblks = 0;
size_t smblks = 0;
size_t hblks = 0;
size_t hblkhd = 0;
size_t usmblks = 0;
size_t fsmblks = 0;
size_t uordblks = 0;
size_t fordblks = 0;
size_t keepcost = 0;
size_t arena;
size_t ordblks;
size_t smblks;
size_t hblks;
size_t hblkhd;
size_t usmblks;
size_t fsmblks;
size_t uordblks;
size_t fordblks;
size_t keepcost;
};
struct BionicLocaleState;
@@ -90,6 +98,7 @@ int mocktail_bionic_cxa_thread_atexit_impl(
mocktail::compat::BionicThreadDestructor destructor, void* argument,
void* dso_handle);
void mocktail_bionic_arc4random_buf(void* buffer, size_t size);
int mocktail_bionic_atoi(const char* text);
mocktail::compat::BionicMallinfoSnapshot mocktail_bionic_mallinfo();
int mocktail_bionic_sysinfo(struct sysinfo* info);
int mocktail_bionic_uname(struct utsname* name);
@@ -25,11 +25,12 @@ void ConfigureBionicPthreadThreadInitializer(
} // namespace mocktail::compat
struct MocktailBionicPthreadAttr;
// Rebuilds attributes instead of passing Android pthread_attr_t to glibc.
extern "C" int mocktail_bionic_pthread_create(pthread_t* thread,
const pthread_attr_t* attr,
void* (*start_routine)(void*),
void* argument);
extern "C" int mocktail_bionic_pthread_create(
pthread_t* thread, const MocktailBionicPthreadAttr* attr,
void* (*start_routine)(void*), void* argument);
// Guest real-time scheduling is denied to avoid the host RLIMIT_RTTIME watchdog.
extern "C" int mocktail_bionic_pthread_setschedparam(
+20
View File
@@ -36,6 +36,11 @@ struct FmodOutputDeviceBridgeProfile {
? std::array<std::size_t, 4>{9, 10, 12, 18}
: std::array<std::size_t, 4>{8, 9, 10, 16};
}
std::array<std::size_t, 6> capture_vtable_indexes() const {
return vtable_layout_version == 2
? std::array<std::size_t, 6>{4, 13, 14, 15, 16, 17}
: std::array<std::size_t, 6>{4, 11, 12, 13, 14, 15};
}
bool valid_vtable_layout() const {
return vtable_layout_version == 1 || vtable_layout_version == 2;
@@ -48,6 +53,19 @@ struct FmodOutputDeviceBridgeProfile {
}
};
// Exact-build metadata for the experimental native VR bridge. The bridge
// interposes DebugDeviceVR slots and a checked world-pointer trampoline.
// All RVAs are validated against machine-code contracts before first use.
struct VrDebugDeviceBridgeProfile {
std::uintptr_t vtable_rva = 0;
std::uintptr_t constructor_rva = 0;
std::uintptr_t state_getter_rva = 0;
std::uintptr_t eye_getter_rva = 0;
std::uintptr_t eye_initializer_rva = 0;
std::uintptr_t emulator_flag_storage_rva = 0;
std::uintptr_t device_create_framebuffer_vtable_offset = 0;
};
struct BuildProfile {
std::string version_name;
int version_code = 0;
@@ -65,6 +83,8 @@ struct BuildProfile {
// output-device virtual slots and restores them during controlled teardown;
// executable guest code is never modified.
std::optional<FmodOutputDeviceBridgeProfile> fmod_output_device_bridge;
// Optional exact-build experimental VR capability; see the struct above.
std::optional<VrDebugDeviceBridgeProfile> vr_debug_device_bridge;
std::string reason;
};
+38
View File
@@ -0,0 +1,38 @@
#ifndef MOCKTAIL_COMPAT_GUEST_ABI_H_
#define MOCKTAIL_COMPAT_GUEST_ABI_H_
#include <elf.h>
#include <string_view>
namespace mocktail::compat {
#if defined(__aarch64__)
// Android ABI name as it appears in APK lib/ directories and build metadata.
inline constexpr std::string_view kGuestAbi = "arm64-v8a";
// APK split-config name carrying the guest native libraries.
inline constexpr std::string_view kGuestApkSplitName = "config.arm64_v8a";
// File name the payload store uses for the split APK.
inline constexpr std::string_view kGuestSplitApkFile =
"split_config.arm64_v8a.apk";
// sha256 map key holding the split APK digest in payload metadata.
inline constexpr std::string_view kGuestSplitApkHashKey = "arm64_v8a_split_apk";
// Short CPU label for device metadata and user-facing strings.
inline constexpr std::string_view kGuestCpuName = "aarch64";
inline constexpr int kGuestElfMachine = EM_AARCH64;
#elif defined(__x86_64__)
inline constexpr std::string_view kGuestAbi = "x86_64";
inline constexpr std::string_view kGuestApkSplitName = "config.x86_64";
inline constexpr std::string_view kGuestSplitApkFile = "split_config.x86_64.apk";
inline constexpr std::string_view kGuestSplitApkHashKey = "x86_64_split_apk";
inline constexpr std::string_view kGuestCpuName = "x86_64";
inline constexpr int kGuestElfMachine = EM_X86_64;
#else
#error "mocktail only supports aarch64 and x86_64 hosts"
#endif
// APK zip entry holding the guest libroblox.so.
inline constexpr std::string_view kGuestLibApkEntryPrefix = "lib/";
} // namespace mocktail::compat
#endif // MOCKTAIL_COMPAT_GUEST_ABI_H_
+9
View File
@@ -109,6 +109,15 @@ struct HostAbiProfile {
size_t NativeMimallocConstructorRangeEndExclusive() const noexcept;
bool ShouldInitializeNativeMimallocThreadAfterConstructor(
size_t index) const noexcept;
// True when both replays have the one verified shape. The native replay is
// one range that runs to the end of .init_array. Its first four
// constructors are the process-load constructor at the thread-initializer
// checkpoint, the JNI singleton initializer, and two wiring entries. The
// host-bridge replay runs the same constructors without the two wiring
// entries. `jni_singleton_constructor` is the .init_array index of the JNI
// singleton initializer.
bool HasVerifiedConstructorPolicy(
size_t jni_singleton_constructor) const noexcept;
bool HasValidNativePreJniBootstrap() const noexcept;
HostAllocatorStrategy ResolveAllocatorStrategy(
bool host_bridges_override_present,
+190
View File
@@ -0,0 +1,190 @@
#ifndef MOCKTAIL_COMPAT_WEB_VIEW_PROTOCOL_CONTRACT_H_
#define MOCKTAIL_COMPAT_WEB_VIEW_PROTOCOL_CONTRACT_H_
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <optional>
#include <string_view>
// Roblox's Subsystem<IWebViewProtocol> getter is not exported. The exported
// WebViewProtocol.initializeAndroidWebViewProtocol calls it first, into a
// stack slot, then reads the protocol from the returned holder:
//
// x86-64: lea slot(%rbp),%rbx; mov %rbx,%rdi; call getter;
// mov (%rbx),%rbx; mov 0x18(%rbx),%rax
// AArch64: add x8, sp, #slot; bl getter; ldr xN, [sp, #slot];
// ldr x8, [xN, #0x18]
//
// These functions find that call site in the initializer's first bytes and
// check the getter's own code. Only relocated or build-varying fields (stack
// slots, frame sizes, PC-relative offsets, the call target) are masked.
// Verified on x86_64 2.734.917, 2.736.1408 and 2.738.1397, and on arm64-v8a
// 2.738.1397 and 2.740.931.
namespace mocktail::compat {
// Covers the call site in every verified build (x86-64 +0x27, AArch64 +0x34).
inline constexpr std::size_t kWebViewProtocolInitializerScanBytes = 128;
inline constexpr std::size_t kWebViewProtocolGetterBytesX86_64 = 44;
inline constexpr std::size_t kWebViewProtocolGetterBytesArm64 = 60;
namespace web_view_protocol_contract_detail {
inline bool MatchesMasked(std::string_view code, std::size_t offset,
std::string_view pattern,
std::string_view wildcard) {
if (offset > code.size() || code.size() - offset < pattern.size()) {
return false;
}
for (std::size_t index = 0; index < pattern.size(); ++index) {
if (wildcard[index] == 'x') continue;
if (code[offset + index] != pattern[index]) return false;
}
return true;
}
// AArch64 instructions are little-endian words.
inline std::uint32_t Word(std::string_view code, std::size_t index) {
std::uint32_t word = 0;
for (std::size_t byte = 0; byte < 4; ++byte) {
word |= static_cast<std::uint32_t>(
static_cast<unsigned char>(code[index * 4 + byte]))
<< (8 * byte);
}
return word;
}
// One AArch64 instruction word: bits outside `mask` may vary.
struct MaskedWord {
std::uint32_t mask;
std::uint32_t value;
};
inline constexpr std::uint32_t kExact = 0xffffffffU;
// adrp Xd, page: immlo and immhi vary.
inline constexpr std::uint32_t kAdrpMask = 0x9f00001fU;
// add Xd, Xn, #imm12: imm12 varies.
inline constexpr std::uint32_t kAddImmediateMask = 0xffc003ffU;
} // namespace web_view_protocol_contract_detail
// Returns the getter address called from the x86-64 initializer at
// `address`, or nullopt unless the frame setup and exactly one call site
// match.
inline std::optional<std::uintptr_t> FindWebViewProtocolGetterCallX86_64(
std::string_view code, std::uintptr_t address) {
using web_view_protocol_contract_detail::MatchesMasked;
// push %rbp; mov %rsp,%rbp
if (!MatchesMasked(code, 0, "\x55\x48\x89\xe5", "....")) {
return std::nullopt;
}
static constexpr std::string_view kCallSite(
"\x48\x8d\x5d\x00\x48\x89\xdf\xe8\x00\x00\x00\x00\x48\x8b\x1b\x48\x8b\x43"
"\x18",
19);
static constexpr std::string_view kCallSiteWildcard("...x....xxxx.......");
static constexpr std::size_t kCallDisplacement = 8;
const std::string_view window =
code.substr(0, kWebViewProtocolInitializerScanBytes);
std::optional<std::size_t> call_site;
for (std::size_t offset = 0; offset < window.size(); ++offset) {
if (MatchesMasked(window, offset, kCallSite, kCallSiteWildcard)) {
if (call_site.has_value()) return std::nullopt;
call_site = offset;
}
}
if (!call_site.has_value()) return std::nullopt;
std::int32_t displacement = 0;
std::memcpy(&displacement, window.data() + *call_site + kCallDisplacement,
sizeof(displacement));
const std::uintptr_t next_instruction =
address + *call_site + kCallDisplacement + sizeof(displacement);
return next_instruction +
static_cast<std::uintptr_t>(static_cast<std::intptr_t>(displacement));
}
// The x86-64 getter stores the holder returned by the subsystem's acquire
// into its result slot (%rdi) and returns that slot.
inline bool HasWebViewProtocolGetterContractX86_64(std::string_view code) {
return web_view_protocol_contract_detail::MatchesMasked(
code, 0,
std::string_view(
"\x55\x48\x89\xe5\x53\x50\x48\x89\xfb\x8a\x05\x00\x00\x00\x00\x84\xc0"
"\x74\x19\x48\x8d\x3d\x00\x00\x00\x00\xe8\x59\x00\x00\x00\x48\x89\x03"
"\x48\x89\xd8\x48\x83\xc4\x08\x5b\x5d\xc3",
kWebViewProtocolGetterBytesX86_64),
"...........xxxx.......xxxx..................");
}
// Returns the getter address called from the AArch64 initializer at
// `address`, or nullopt unless exactly one call site matches.
inline std::optional<std::uintptr_t> FindWebViewProtocolGetterCallArm64(
std::string_view code, std::uintptr_t address) {
using web_view_protocol_contract_detail::Word;
const std::size_t words =
code.substr(0, kWebViewProtocolInitializerScanBytes).size() / 4;
std::optional<std::uintptr_t> getter;
for (std::size_t index = 0; index + 4 <= words; ++index) {
const std::uint32_t slot_address = Word(code, index);
const std::uint32_t call = Word(code, index + 1);
const std::uint32_t handle_load = Word(code, index + 2);
const std::uint32_t protocol_load = Word(code, index + 3);
// add x8, sp, #slot
if ((slot_address & 0xffc003ffU) != 0x910003e8U) continue;
// bl getter
if ((call & 0xfc000000U) != 0x94000000U) continue;
// ldr xN, [sp, #slot]
if ((handle_load & 0xffc003e0U) != 0xf94003e0U) continue;
const std::uint32_t slot = (slot_address >> 10) & 0xfffU;
if (((handle_load >> 10) & 0xfffU) * 8 != slot) continue;
// ldr x8, [xN, #0x18]
const std::uint32_t handle_register = handle_load & 0x1fU;
if (protocol_load != (0xf9400c08U | (handle_register << 5))) continue;
if (getter.has_value()) return std::nullopt;
// imm26 is a signed word offset from the bl instruction.
const std::int64_t imm26 = call & 0x03ffffffU;
const std::int64_t words_from_call =
(imm26 & 0x02000000) != 0 ? imm26 - 0x04000000 : imm26;
getter = address + (index + 1) * 4 +
static_cast<std::uintptr_t>(words_from_call * 4);
}
return getter;
}
// The AArch64 getter saves its result slot (x8) and stores the holder
// returned by the subsystem's acquire there.
inline bool HasWebViewProtocolGetterContractArm64(std::string_view code) {
using namespace web_view_protocol_contract_detail;
static constexpr MaskedWord kGetter[] = {
{kExact, 0xa9be7bfdU}, // stp x29, x30, [sp, #-0x20]!
{kExact, 0xf9000bf3U}, // str x19, [sp, #0x10]
{kExact, 0x910003fdU}, // mov x29, sp
{kAdrpMask, 0x90000009U}, // adrp x9, guard
{kAddImmediateMask, 0x91000129U}, // add x9, x9, :lo12:guard
{kExact, 0xaa0803f3U}, // mov x19, x8
{kExact, 0x08dffd29U}, // ldarb w9, [x9]
{kExact, 0x36000109U}, // tbz w9, #0, initialize
{kAdrpMask, 0x90000000U}, // adrp x0, holder
{kAddImmediateMask, 0x91000000U}, // add x0, x0, :lo12:holder
{kExact, 0x94000017U}, // bl acquire
{kExact, 0xf9000260U}, // str x0, [x19]
{kExact, 0xf9400bf3U}, // ldr x19, [sp, #0x10]
{kExact, 0xa8c27bfdU}, // ldp x29, x30, [sp], #0x20
{kExact, 0xd65f03c0U}, // ret
};
static_assert(sizeof(kGetter) / sizeof(kGetter[0]) * 4 ==
kWebViewProtocolGetterBytesArm64);
if (code.size() < kWebViewProtocolGetterBytesArm64) return false;
for (std::size_t index = 0; index < sizeof(kGetter) / sizeof(kGetter[0]);
++index) {
if ((Word(code, index) & kGetter[index].mask) != kGetter[index].value) {
return false;
}
}
return true;
}
} // namespace mocktail::compat
#endif // MOCKTAIL_COMPAT_WEB_VIEW_PROTOCOL_CONTRACT_H_
+6
View File
@@ -48,9 +48,15 @@ struct PlatformIdentity {
std::string manufacturer = "Mocktail";
std::string model = "Mocktail Headless";
std::string brand = "Mocktail";
#if defined(__aarch64__)
std::string device_code = "linux-arm64";
std::string device_sku = "mocktail-arm64";
std::string soc_model = "aarch64";
#else
std::string device_code = "linux-x86_64";
std::string device_sku = "mocktail-x86_64";
std::string soc_model = "x86_64";
#endif
};
struct RobloxCredentialView {
@@ -6,6 +6,9 @@
#if defined(__x86_64__)
#include <xmmintrin.h>
#endif
#if defined(__aarch64__)
#include <cstdint>
#endif
namespace mocktail::audio {
@@ -22,16 +25,23 @@ class FmodThreadFloatingPointMode final {
delete;
bool Enable(const char* thread_name) noexcept {
#if defined(__x86_64__)
#if defined(__x86_64__) || defined(__aarch64__)
if (active_ || thread_name == nullptr) return false;
const std::string_view name(thread_name);
if (name != "FMOD mixer thread" && name != "FMOD feeder thread" &&
name != "FMOD Convolution thread" && name != "FMOD Worker Thread") {
return false;
}
#if defined(__x86_64__)
const unsigned int control = _mm_getcsr();
original_mode_ = control & kModeMask;
_mm_setcsr(control | kModeMask);
#else
std::uint64_t control;
__asm__ volatile("mrs %0, fpcr" : "=r"(control));
original_mode_ = control & kModeMask;
__asm__ volatile("msr fpcr, %0" ::"r"(control | kModeMask));
#endif
active_ = true;
return true;
#else
@@ -41,11 +51,18 @@ class FmodThreadFloatingPointMode final {
}
void Restore() noexcept {
#if defined(__x86_64__)
#if defined(__x86_64__) || defined(__aarch64__)
if (active_) {
// Restore only our two bits, preserving rounding, exception masks and
// status flags set by the guest while the thread was attached.
#if defined(__x86_64__)
_mm_setcsr((_mm_getcsr() & ~kModeMask) | original_mode_);
#else
std::uint64_t control;
__asm__ volatile("mrs %0, fpcr" : "=r"(control));
__asm__ volatile("msr fpcr, %0" ::"r"((control & ~kModeMask) |
original_mode_));
#endif
active_ = false;
}
#endif
@@ -56,6 +73,13 @@ class FmodThreadFloatingPointMode final {
static constexpr unsigned int kModeMask = (1U << 15) | (1U << 6);
unsigned int original_mode_ = 0;
bool active_ = false;
#elif defined(__aarch64__)
// FPCR FZ flushes single/double subnormals; FZ16 covers the half-precision
// path used by some voice DSP filters.
static constexpr std::uint64_t kModeMask =
(std::uint64_t{1} << 24) | (std::uint64_t{1} << 19);
std::uint64_t original_mode_ = 0;
bool active_ = false;
#endif
};
@@ -61,8 +61,7 @@ class RobloxOutputDeviceBridge final {
void SelectDevice(int index, bool input = false);
void ConstructGuestString(void* destination, std::string_view value) const;
std::unique_ptr<NativeInputCapture> capture_;
bool capture_profile_supported_ = false;
std::shared_ptr<NativeInputCapture> capture_;
std::array<std::uintptr_t, 6> original_capture_methods_{};
mutable std::mutex mutex_;
compat::FmodOutputDeviceBridgeProfile profile_;
+3
View File
@@ -16,6 +16,9 @@ struct AngleProbeOptions {
bool allow_software_device = false;
};
// Checks ANGLE entry points and client extensions without initializing a display.
BackendCapability InspectAngleLibraries(const AngleProbeOptions& options);
// Loads a pinned ANGLE pair, verifies the required EGL/GLES symbols, and
// initializes an EGL_PLATFORM_ANGLE Vulkan display. No context is fabricated.
BackendCapability ProbeAngleVulkan(const AngleProbeOptions& options);
@@ -0,0 +1,150 @@
#ifndef MOCKTAIL_GRAPHICS_CHROME_TRACE_WRITER_H_
#define MOCKTAIL_GRAPHICS_CHROME_TRACE_WRITER_H_
#include <condition_variable>
#include <cstdint>
#include <cstdio>
#include <filesystem>
#include <initializer_list>
#include <memory>
#include <mutex>
#include <string>
#include <thread>
namespace mocktail {
namespace graphics {
// Environment variable naming the trace file the Vulkan adapter writes.
inline constexpr char kProfileTraceEnvironment[] = "MOCKTAIL_PROFILE_TRACE";
struct TraceArg {
const char* key;
std::int64_t value;
};
// CLOCK_MONOTONIC in nanoseconds.
std::uint64_t TraceClockNanos();
// CPU time used so far by the calling thread, in nanoseconds.
std::uint64_t TraceThreadCpuNanos();
// CPU time used so far by every thread of the process, in nanoseconds.
std::uint64_t TraceProcessCpuNanos();
// CPU time spent between two successive presents.
struct FrameCpuSample {
double process_ms = 0;
// Set only when the previous present came from the same thread; thread CPU
// clocks of different threads cannot be subtracted.
bool has_thread = false;
double thread_ms = 0;
};
// Turns CPU clocks read at each present into per-frame CPU time.
// Thread-safe.
class FrameCpuSampler final {
public:
// Returns false for the first present, which ends no frame.
bool Sample(std::uint64_t thread_id, std::uint64_t thread_cpu_ns,
std::uint64_t process_cpu_ns, FrameCpuSample* sample);
private:
std::mutex mutex_;
bool has_previous_ = false;
std::uint64_t previous_thread_id_ = 0;
std::uint64_t previous_thread_cpu_ns_ = 0;
std::uint64_t previous_process_cpu_ns_ = 0;
};
// Writes the Trace Event Format JSON array read by chrome://tracing and
// ui.perfetto.dev. Recording threads only format into a shared buffer; a
// background thread owns file writes, so disk stalls do not land inside the
// measured calls. Events are written in arrival order. Each write ends the
// file with the closing bracket, so a killed session leaves a loadable trace
// holding everything written before it died.
// Names, categories, and keys must outlive the call.
class ChromeTraceWriter final {
public:
static std::unique_ptr<ChromeTraceWriter> Open(
const std::filesystem::path& path, std::string* error);
~ChromeTraceWriter();
ChromeTraceWriter(const ChromeTraceWriter&) = delete;
ChromeTraceWriter& operator=(const ChromeTraceWriter&) = delete;
// A complete ("X") slice on the calling thread. Slices on one thread must
// nest, which scoped timing gives.
void Slice(const char* name, const char* category, std::uint64_t start_ns,
std::uint64_t end_ns, std::initializer_list<TraceArg> args = {});
void Slice(const char* name, const char* category, std::uint64_t start_ns,
std::uint64_t end_ns, const TraceArg* args,
std::size_t arg_count);
// A counter ("C") sample; each name becomes one counter track.
void Counter(const char* name, std::uint64_t timestamp_ns, double value);
// Returns once every event recorded before the call is on disk.
void Flush();
// Flushes, closes the file, and drops later events. Idempotent.
void Close();
private:
explicit ChromeTraceWriter(std::FILE* file);
void Append(const char* event, std::size_t size);
void WriterLoop();
void WritePending();
std::FILE* file_;
const int owner_pid_;
std::mutex file_mutex_; // Guards file_ and spare_.
std::string spare_;
std::mutex mutex_;
std::condition_variable wake_;
std::string pending_;
bool first_event_ = true;
bool closed_ = false;
bool stopping_ = false;
std::thread writer_;
};
// The process trace named by MOCKTAIL_PROFILE_TRACE, opened on first use.
// Null when profiling is off or the file could not be opened. The writer is
// never destroyed; it is closed when the process exits normally.
ChromeTraceWriter* ActiveProfileTrace();
// Times its own lifetime as one slice. A null writer records nothing.
class TraceScope final {
public:
TraceScope(ChromeTraceWriter* writer, const char* name,
const char* category)
: writer_(writer),
name_(name),
category_(category),
start_ns_(writer != nullptr ? TraceClockNanos() : 0) {}
~TraceScope() {
if (writer_ != nullptr) {
writer_->Slice(name_, category_, start_ns_, TraceClockNanos(), args_,
arg_count_);
}
}
TraceScope(const TraceScope&) = delete;
TraceScope& operator=(const TraceScope&) = delete;
// Keeps the first kMaxArgs arguments.
void Arg(const char* key, std::int64_t value) {
if (writer_ != nullptr && arg_count_ < kMaxArgs) {
args_[arg_count_++] = {key, value};
}
}
private:
static constexpr std::size_t kMaxArgs = 4;
ChromeTraceWriter* writer_;
const char* name_;
const char* category_;
std::uint64_t start_ns_;
TraceArg args_[kMaxArgs]{};
std::size_t arg_count_ = 0;
};
} // namespace graphics
} // namespace mocktail
#endif // MOCKTAIL_GRAPHICS_CHROME_TRACE_WRITER_H_
+8
View File
@@ -66,6 +66,14 @@ struct EtcDecodeJob {
void DecodeEtcJobs(EtcDecodeJob* jobs, std::size_t count,
unsigned worker_count);
// Calls run(context) worker_count times, once on the caller and the rest on
// pooled decode workers that run alongside it, and returns when every call
// has returned. `run` must be safe to call from several threads at once, so
// it should drain a shared queue. When the pool is busy with another batch,
// or no worker could be started, run is called on the caller alone.
void RunOnDecodeWorkers(void (*run)(void*), void* context,
unsigned worker_count);
} // namespace mocktail::graphics
#endif // MOCKTAIL_GRAPHICS_ETC2_DECODER_H_
@@ -2,6 +2,7 @@
#define MOCKTAIL_GRAPHICS_PRESENT_MODE_POLICY_H_
#include <cstdint>
#include <cstdio>
#include <string_view>
#include <vector>
@@ -29,6 +30,36 @@ FilterPresentModes(PresentModePolicy policy,
const char *PresentModePolicyName(PresentModePolicy policy);
const char *PresentModeKhrName(VkPresentModeKHR mode);
// Creates the swapchain with the mode in `info`. A driver can reject a mode
// that the device did not enable, so a failed create is retried once with the
// mode the game asked for. `create` takes the info and returns a VkResult.
template <typename Create>
VkResult CreateSwapchainWithPresentModeFallback(VkSwapchainCreateInfoKHR &info,
VkPresentModeKHR requested,
Create create) {
const VkResult result = create(info);
if (result == VK_SUCCESS || info.presentMode == requested) {
return result;
}
switch (result) {
case VK_ERROR_OUT_OF_HOST_MEMORY:
case VK_ERROR_OUT_OF_DEVICE_MEMORY:
case VK_ERROR_DEVICE_LOST:
case VK_ERROR_SURFACE_LOST_KHR:
case VK_ERROR_NATIVE_WINDOW_IN_USE_KHR:
return result; // Another present mode cannot fix these.
default:
break;
}
std::fprintf(stderr,
" [vulkan] swapchain presentMode %s failed (%d); retrying "
"with %s\n",
PresentModeKhrName(info.presentMode), static_cast<int>(result),
PresentModeKhrName(requested));
info.presentMode = requested;
return create(info);
}
// Extra images so acquire does not wait on the compositor-held buffer.
std::uint32_t PreferSwapchainMinImageCount(PresentModePolicy policy,
std::uint32_t requested,
@@ -35,6 +35,10 @@ void ResampleRgba(const RgbaImage& source, std::uint32_t width,
void ResampleRgba(const std::uint8_t* source, std::uint32_t source_width,
std::uint32_t source_height, std::uint32_t width,
std::uint32_t height, std::uint8_t* destination);
void ResampleRgba(const std::uint8_t* source, std::uint32_t source_width,
std::uint32_t source_height, std::size_t source_stride_bytes,
std::uint32_t width, std::uint32_t height,
std::uint8_t* destination);
// Replacement textures keyed by the hash of a texture's level-0 compressed
// bytes. An override lives at <override_dir>/<hash>.png. With a dump
@@ -30,6 +30,18 @@ bool Etc2UploadByteCounts(EtcFormat format, const VkExtent3D& extent,
std::uint32_t layer_count, VkDeviceSize* compressed,
VkDeviceSize* decoded);
// Sums every mip level and layer; `scale` multiplies the host image's
// level-0 width and height only.
bool Etc2ImageByteCounts(EtcFormat format, const VkExtent3D& extent,
std::uint32_t mip_levels, std::uint32_t layers,
std::uint32_t scale, VkDeviceSize* compressed,
VkDeviceSize* decoded);
struct EmulatedTextureBytes {
VkDeviceSize compressed = 0;
VkDeviceSize decoded = 0;
};
// Whether emulated ETC2 is reported to the application as a supported
// feature, from the MOCKTAIL_ADVERTISE_ETC2 value: "0" hides it, anything
// else (or unset) reports it. Hidden ETC2 still decodes ETC2 images the
@@ -47,17 +59,30 @@ inline constexpr std::uint32_t kSmallTextureMaxExtent = 64;
inline constexpr std::uint32_t kDefaultSmallTextureUpscale = 4;
std::uint32_t SmallTextureUpscale(const char* value);
// Staging for decoded uploads is carved out of host-visible blocks this
// large; an upload that needs more gets a block of its own.
inline constexpr VkDeviceSize kEtc2StagingBlockBytes = 16 * 1024 * 1024;
// A timeline semaphore and the value an asynchronous decode will signal.
// `semaphore` is VK_NULL_HANDLE when the decode already finished on the
// calling thread and the submit needs no extra wait.
struct Etc2SubmitWait {
VkSemaphore semaphore = VK_NULL_HANDLE;
std::uint64_t value = 0;
};
// Reports ETC2/EAC support on hosts without native ETC2 (desktop GPUs) and
// stores those textures decoded. Images are created with the host format;
// each staged upload into one is redirected to a host-visible staging buffer
// that is filled by decoding the application's staging bytes when the
// command buffer is submitted. Every submit decodes again, as a native copy
// re-reads its source on each execution.
// Staging buffers live until their command buffer is begun, reset or freed.
// Staging ranges live until their command buffer is begun, reset or freed.
// MOCKTAIL_DISABLE_ETC2_EMULATION=1 turns the emulation off.
class VulkanEtc2Emulation final {
public:
VulkanEtc2Emulation();
explicit VulkanEtc2Emulation(
VkDeviceSize staging_block_bytes = kEtc2StagingBlockBytes);
~VulkanEtc2Emulation();
VulkanEtc2Emulation(const VulkanEtc2Emulation&) = delete;
VulkanEtc2Emulation& operator=(const VulkanEtc2Emulation&) = delete;
@@ -66,10 +91,14 @@ class VulkanEtc2Emulation final {
bool PhysicalDeviceNeedsEmulation(VkPhysicalDevice physical_device,
PFN_vkGetPhysicalDeviceFeatures host);
// `timeline_semaphore` states whether the device was created with the
// timelineSemaphore feature enabled. Without it every decode runs on the
// calling thread.
void RegisterDevice(VkDevice device, VkPhysicalDevice physical_device,
bool emulated,
const VkPhysicalDeviceMemoryProperties& memory,
PFN_vkGetDeviceProcAddr get_device_proc_addr);
PFN_vkGetDeviceProcAddr get_device_proc_addr,
bool timeline_semaphore = false);
void DestroyDevice(VkDevice device);
VkResult CreateImage(VkDevice device, const VkImageCreateInfo* create_info,
@@ -112,14 +141,33 @@ class VulkanEtc2Emulation final {
const VkCommandBuffer* secondaries);
// Decodes every upload recorded into these command buffers and the
// secondary command buffers they execute. Large batches decode on worker
// threads; this returns once every upload is decoded.
void PrepareSubmit(const VkCommandBuffer* command_buffers,
std::uint32_t count);
// secondary command buffers they execute.
//
// With `allow_async` on a device that has a timeline semaphore, the decode
// runs on worker threads, and so does the gather of the compressed bytes
// whenever the application already has them mapped. The returned semaphore
// and value must then be added to the submit's waits, so the GPU copy
// cannot execute before the decode finishes. Values are signalled in the
// order they are handed out.
//
// Those threads read the application's source bytes after this returns,
// which Vulkan allows: the application may not write them between the
// submit and the copy's completion. Unmapping or freeing that memory waits
// for the decodes still reading it.
//
// Returns `{VK_NULL_HANDLE, 0}` when there was nothing to decode, when
// `allow_async` is false, or when the device has no timeline semaphore. In
// those cases every upload is already decoded when this returns.
Etc2SubmitWait PrepareSubmit(const VkCommandBuffer* command_buffers,
std::uint32_t count, bool allow_async = false);
// Destroys the staging buffers owned by a command buffer that is no longer
// pending.
// pending, after waiting for any decode still writing into them.
void ReleaseCommandBuffer(VkCommandBuffer command_buffer);
// Traced as the "emulated texture (MB)" and "compressed texture (MB)"
// counters.
EmulatedTextureBytes TextureBytes() const;
struct State;
private:
@@ -152,6 +152,10 @@ struct MouseMotionEvent {
float delta_x = 0.0f;
float delta_y = 0.0f;
std::uint32_t buttons = 0;
// True while the window holds relative mouse mode. The absolute coordinates
// are meaningless then: X11 reports zeroes, Wayland reports a position that
// accumulates the deltas and can leave the window entirely.
bool relative = false;
};
struct MouseButtonEvent {
@@ -160,6 +164,8 @@ struct MouseButtonEvent {
std::uint8_t clicks = 0;
float x = 0.0f;
float y = 0.0f;
// See MouseMotionEvent::relative.
bool relative = false;
};
struct MouseWheelEvent {
@@ -167,6 +173,8 @@ struct MouseWheelEvent {
float delta_y = 0.0f;
float mouse_x = 0.0f;
float mouse_y = 0.0f;
// See MouseMotionEvent::relative.
bool relative = false;
};
struct TouchEvent {
+35
View File
@@ -0,0 +1,35 @@
#ifndef MOCKTAIL_VR_HEADSET_DETECTION_H_
#define MOCKTAIL_VR_HEADSET_DETECTION_H_
#include <chrono>
#include <string>
namespace mocktail::vr {
// Set on the child process that answers DetectSteamVrHeadset. main() checks
// it first and returns RunHeadsetProbeChild() without starting Roblox.
inline constexpr const char* kHeadsetProbeEnvironment =
"MOCKTAIL_VR_HEADSET_PROBE";
struct HeadsetDetection {
bool headset = false;
std::string reason;
};
// vr.mode: auto. True only when this user's SteamVR is running and its OpenXR
// runtime reports a head-mounted display. SteamVR is never started. The
// runtime is asked from a child process (`self_executable` with
// kHeadsetProbeEnvironment set) so its libraries never load into this one
// before the VR backend arms.
HeadsetDetection DetectSteamVrHeadset(
const std::string& self_executable,
std::chrono::milliseconds timeout = std::chrono::seconds(5),
const std::string& proc_root = "/proc");
// The child side: probes the OpenXR runtime in XR_RUNTIME_JSON and returns
// the probe's exit code (0 when a headset is available).
int RunHeadsetProbeChild();
} // namespace mocktail::vr
#endif // MOCKTAIL_VR_HEADSET_DETECTION_H_
+665
View File
@@ -0,0 +1,665 @@
#ifndef MOCKTAIL_VR_OPENXR_BACKEND_H_
#define MOCKTAIL_VR_OPENXR_BACKEND_H_
#include <vulkan/vulkan.h>
#include <array>
#include <atomic>
#include <cstdint>
#include <mutex>
#include <string>
#include <unordered_map>
#include <vector>
#include <memory>
#include "mocktail/status.h"
#include "mocktail/vr/virtual_keyboard.h"
#include "mocktail/vr/vr_mirror.h"
#include "mocktail/vr/xr_actions.h"
#include "mocktail/vr/xr_controller.h"
namespace mocktail::vr {
namespace internal {
// Swapchain format for guest eyes of `eye` given the runtime's formats:
// `eye` itself, else its UNORM/SRGB sibling (a raw byte copy, since the guest
// writes sRGB-encoded bytes either way), else the other channel order with
// the same sRGB-ness (a blit that only swizzles). VK_FORMAT_UNDEFINED when
// only a gamma-converting blit remains.
VkFormat PreferredEyeSwapchainFormat(VkFormat eye,
const std::vector<std::int64_t>& available);
// Consume binary present dependencies exactly once. On success a later present
// must omit them. VK_NOT_READY means nothing was consumed and the guest's
// present proceeds unchanged; any other failure consumed them (or lost the
// device) and must be propagated, not retried.
VkResult WaitForPresentDependencies(VkDevice device, VkQueue queue,
const VkPresentInfoKHR& present,
VkFence fence, PFN_vkResetFences reset,
PFN_vkQueueSubmit submit,
PFN_vkWaitForFences wait);
// Hands binary present dependencies to the GPU instead: one empty batch on
// `queue` waits on them and signals `signal`, which the present waits on
// instead. Work submitted to `queue` afterwards is ordered after the waits.
// The CPU never blocks. VK_NOT_READY means nothing was consumed.
VkResult ChainPresentDependencies(VkDevice device, VkQueue queue,
const VkPresentInfoKHR& present,
VkSemaphore signal, PFN_vkQueueSubmit submit);
// Makes the Vulkan adapter serialize queue calls for the rest of the process.
// Called when a backend arms; the adapter library may load before or after.
void RequestVulkanQueueSharing();
} // namespace internal
// Resolved --vr output mode. kXrOutput drives the real Roblox stereo images
// into an OpenXR projection layer; kNativeOnly keeps the diagnostic native
// stereo prototype without any runtime requirement. Selected through
// MOCKTAIL_VR_BACKEND ("xr" default, "native" diagnostic).
enum class VrBackendMode {
kDisabled,
kNativeOnly,
kXrOutput,
};
// Explicit XR_RUNTIME_JSON wins. Runtime preference can select SteamVR (ALVR),
// WiVRn, or the registered OpenXR runtime without changing system registration.
std::string SelectVrRuntimeManifest(const std::string& explicit_manifest,
const std::vector<std::string>& candidates);
std::vector<std::string> VrRuntimeManifestCandidates(
const std::string &preference, const std::string &home,
const std::string &config_home, const std::string &config_dirs);
struct VrRuntimeDiscovery {
std::vector<std::string> candidates;
std::string reason;
};
VrRuntimeDiscovery DiscoverVrRuntime(
const std::string& preference, const std::string& home,
const std::string& config_home, const std::string& config_dirs,
const std::string& runtime_dir, const std::string& proc_root = "/proc");
std::string VrRuntimeUnavailableHint(bool user_selected, const std::string& manifest);
// SteamVR's OpenXR manifest when this user's vrserver is running, else empty.
std::string RunningSteamVrManifest(const std::string& proc_root = "/proc");
VrBackendMode ResolveVrBackendMode(bool vr_enabled);
const char* VrBackendModeName(VrBackendMode mode);
// One deterministic head pose sample. Position is metres, orientation is a
// quaternion (x, y, z, w) in the OpenXR LOCAL reference space.
// VrHandPose (tracked controller hands) is defined in xr_controller.h.
struct ScriptedPoseSample {
float position[3] = {0.f, 0.f, 0.f};
float orientation[4] = {0.f, 0.f, 0.f, 1.f};
float eye_offset[2][3] = {}; // metres in head space
float eye_fov[2][4] = {}; // left, right, up, down angles
// Index 0 = left, 1 = right (Roblox UserCFrame LeftHand/RightHand).
VrHandPose hands[2];
float floor_position[3] = {};
float floor_orientation[4] = {0.f, 0.f, 0.f, 1.f};
bool floor_valid = false;
// Verified DebugDeviceVR input channels, published with the same pose.
float controller_channels[28] = {};
bool controllers_connected = false;
std::uint64_t frame = 0;
bool valid = false;
};
// Production OpenXR backend for the native stereo prototype.
//
// Lifecycle: main arms the backend before Roblox startup (xrCreateInstance
// and xrGetSystem fail fast with a clear message when no runtime exists).
// The Vulkan adapter delegates the guest's vkCreateInstance/vkCreateDevice
// through xrCreateVulkanInstanceKHR/xrCreateVulkanDeviceKHR so the runtime's
// extension and GPU requirements are satisfied by the very device Roblox
// renders with. The session is created on that device; per-frame XR work runs
// on the render thread from the adapter's host-present notification:
// xrEndFrame(N) with the two eye images of frame N copied into the
// projection swapchains, then xrWaitFrame/xrBeginFrame/xrLocateViews for
// frame N+1 and publishing the scripted/runtime head pose that the device
// bridge injects into Roblox before its eye passes run.
//
// EGL/OpenGL ES uses XR_MNDX_egl_enable on the guest SDL context and GPU
// framebuffer blits into GLES swapchain textures. It shares the same frame
// scheduling, tracking, pose application and session recovery below.
//
// Eye image provenance is recorded in the adapter during the guest eye
// initializer (owner = DebugDeviceVR object) and bound to an eye index only
// through the real per-frame eye-getter request plus the actual render-pass
// attachment, mirroring the validated evidence-layer semantics. Creation
// order or size alone never selects an eye.
//
// All public entry points are exception-free at their boundary; the
// implementation is compiled with exceptions like openxr_preview.cc and
// translates failures into Status/log outcomes.
class GlesTransport;
enum class VrGraphicsApi { kVulkan, kOpenGles };
class OpenXrBackend final {
public:
OpenXrBackend() = default;
~OpenXrBackend();
OpenXrBackend(const OpenXrBackend&) = delete;
OpenXrBackend& operator=(const OpenXrBackend&) = delete;
// Claims the process-wide backend slot and creates XrInstance/system.
Status Arm(VrGraphicsApi graphics_api = VrGraphicsApi::kVulkan);
// EGL context must be current on the calling thread. Resolver bypasses guest
// hooks.
Status AttachGlesContext(void *display, void *config, void *context,
void *egl_get_proc, void *(*resolve)(const char *));
void DetachGlesContext(void *context);
void NoteGlesPresent();
void *WrapGlesProcAddress(const char *name, void *raw);
// Idempotent full teardown (XR resources first, then the instance).
void Disarm();
bool armed() const { return armed_.load(std::memory_order_acquire); }
// Adapter delegation. Return false when the backend does not own creation
// (caller falls back to the plain host loader path).
bool CreateVulkanInstance(const VkInstanceCreateInfo* create_info,
const VkAllocationCallbacks* allocator,
VkInstance* instance, VkResult* result);
bool CreateVulkanDevice(VkPhysicalDevice physical_device,
const VkDeviceCreateInfo* create_info,
const VkAllocationCallbacks* allocator,
VkDevice* device, VkResult* result);
void NoteDeviceDestroyed(VkDevice device);
void NoteInstanceDestroyed(VkInstance instance);
void NoteQueue(VkDevice device, VkQueue queue, std::uint32_t family,
std::uint32_t index);
std::vector<std::uint32_t> DesktopQueueFamilies(VkDevice device) const;
void RecordCommandPool(VkDevice device, VkCommandPool pool,
std::uint32_t family);
void RecordCommandBuffers(VkDevice device, VkCommandPool pool,
std::uint32_t count,
const VkCommandBuffer* buffers);
void ForgetCommandBuffers(std::uint32_t count,
const VkCommandBuffer* buffers);
void ForgetCommandPool(VkCommandPool pool);
void ResetCommandPool(VkCommandPool pool);
void ResetCommandBuffer(VkCommandBuffer buffer);
void NoteCommandBuffersSubmitted(VkQueue queue, std::uint32_t count,
const VkCommandBuffer* buffers);
VkImageLayout NormalizeDesktopBarrier(VkCommandBuffer command, VkImage image,
std::uint32_t* source,
std::uint32_t* destination,
VkImageLayout* old_layout,
VkImageLayout new_layout);
// Resource provenance recording (adapter wrappers).
bool WantsResourceRecords() const {
return recording_.load(std::memory_order_acquire);
}
void RecordImage(VkDevice device, VkImage image,
const VkImageCreateInfo* info);
void RecordImageView(VkImageView view, VkImage image);
void RecordFramebuffer(VkFramebuffer framebuffer,
const VkFramebufferCreateInfo* info);
void RecordRenderPass(VkRenderPass render_pass,
const VkRenderPassCreateInfo* info);
void NoteDestroyImage(VkImage image);
void NoteDestroyImageView(VkImageView view);
void NoteDestroyFramebuffer(VkFramebuffer framebuffer);
void NoteRenderPassBegin(VkCommandBuffer command_buffer,
const VkRenderPassBeginInfo* info);
bool EyeBindingComplete() const {
return eyes_bound_.load(std::memory_order_acquire);
}
// Render-thread frame cycle at host present begin.
void NoteHostPresent(VkQueue queue, VkDevice device);
// VK_NOT_READY leaves the present untouched until eye submission provenance
// is known. VK_SUCCESS means its waits were consumed on the graphics queue,
// so the eye copies that follow there are ordered after them. The present
// must then wait on *ready instead (none when it is VK_NULL_HANDLE).
VkResult PrepareHostPresent(VkQueue queue, VkDevice device,
const VkPresentInfoKHR* info, VkSemaphore* ready);
void RecordDesktopSwapchain(VkDevice device, VkSwapchainKHR swapchain,
const VkSwapchainCreateInfoKHR* info,
const VkImage* images, unsigned count,
bool converted_to_concurrent = false);
// On success the original present semaphores have been consumed by the
// mirror blit on the GPU. The caller must present waiting only on *ready,
// which the blit signals. The CPU never waits for the guest's frame here.
VkResult MirrorDesktop(VkQueue queue, const VkPresentInfoKHR* info,
VkSemaphore* ready);
// Pose published for the frame that is about to render. The device bridge
// reads it once per frame on the render thread and injects it into the
// guest. valid=false until the XR session located a usable head pose.
// hands[0]=left, hands[1]=right carry this frame's tracked controller grip
// poses (metres, LOCAL space) when a controller layer is active.
ScriptedPoseSample PublishedHeadPose() const;
bool RecommendedEyeExtent(std::uint32_t* width, std::uint32_t* height) const;
void NotePoseApplied(void* owner, std::uint64_t frame);
// Controller input delivery, consumed by the window-thread input runtime
// through the C ABI in xr_controller_abi.h. TakeControllerDelivery pops one
// batch produced by the once-per-frame action sync; it returns false when
// no controller layer is active or nothing is queued. The snapshot exposes
// the current levels for overflow resync. Haptics requests are thread-safe.
bool TakeControllerDelivery(ControllerDelivery* out);
ControllerSnapshot ControllerSnapshotForFrame() const;
void RequestControllerHaptics(int hand, float amplitude,
std::uint64_t duration_ns, float frequency_hz);
void StopControllerHaptics(int hand);
// Diagnostics.
std::uint64_t submitted_frames() const {
return submitted_frames_.load(std::memory_order_relaxed);
}
std::uint64_t skipped_frames() const {
return skipped_frames_.load(std::memory_order_relaxed);
}
bool session_running() const {
return session_running_.load(std::memory_order_relaxed);
}
std::string runtime_name() const;
private:
friend struct OpenXrBackendTestAccess;
struct ImageRecord {
VkImage image = VK_NULL_HANDLE;
VkDevice device = VK_NULL_HANDLE;
std::uint32_t width = 0;
std::uint32_t height = 0;
VkFormat format = VK_FORMAT_UNDEFINED;
VkImageUsageFlags usage = 0;
VkSampleCountFlagBits samples = VK_SAMPLE_COUNT_1_BIT;
void* owner = nullptr;
std::uint64_t order = 0;
};
struct EyeBinding {
bool valid = false;
std::uint64_t frame = 0;
VkImage image = VK_NULL_HANDLE;
VkFramebuffer framebuffer = VK_NULL_HANDLE;
VkImageLayout final_layout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL;
void* owner = nullptr;
std::uint32_t width = 0;
std::uint32_t height = 0;
VkFormat format = VK_FORMAT_UNDEFINED;
};
struct EyeSwapchain {
void* swapchain = nullptr; // XrSwapchain (opaque to keep the header XR-free)
std::vector<VkImage> images;
std::vector<std::uint32_t> gl_images;
std::uint32_t acquired_index = 0;
bool image_acquired = false;
std::uint32_t width = 0;
std::uint32_t height = 0;
VkFormat format = VK_FORMAT_UNDEFINED;
};
bool InitializeSessionLocked(VkPhysicalDevice physical_device,
VkDevice device,
const VkDeviceCreateInfo* create_info,
std::string* error);
// Session-owned controller action layer (poses, buttons, axes, haptics).
// Created at instance level, attached per session, detached on teardown so
// a replacement session never reuses stale spaces.
XrActions actions_;
bool actions_created_ = false;
// Shared by action sync, snapshots, haptics and lifecycle operations.
// Acquired after mutex_ on the render thread, but never held across
// xrWaitFrame; the input thread takes only this controller lock.
mutable std::mutex controller_mutex_;
// Syncs actions once per frame at the predicted display time and folds the
// resulting hand poses into published_pose_ before the guest renders.
void SyncControllersLocked(std::uint64_t predicted_display_time);
bool InitializeGlesSessionLocked(std::string *error);
bool InitializeSessionResourcesLocked(std::string *error);
bool CopyGlesEyesIntoSwapchains();
bool EnsureXrInstanceLocked(std::string* error = nullptr);
void DestroyXrInstanceLocked();
void DisarmLocked();
void* ResolveFunction(const char* name) const;
void SetupDeviceAfterCreationLocked(VkPhysicalDevice physical_device,
VkDevice device,
const VkDeviceCreateInfo* create_info);
void TeardownSessionLocked(const char* reason, bool preserve_device = false);
void HandleSessionState(int state);
void HandleReferenceSpaceChange(std::int64_t change_time);
bool RecoverSessionLocked(std::uint64_t now_ns);
// False only when the runtime names a GPU other than the guest device's.
bool RuntimeUsesGuestGpuLocked();
void PollSessionEvents();
bool SupportsPresentQueueLocked(VkQueue queue) const;
VkQueue GraphicsQueueForPresentLocked(VkQueue present) const;
bool unsupported_present_queue_logged_ = false;
// Drops the pose/images of the frame currently in flight without touching
// guest ownership, so the next xrEndFrame submits no layer instead of an
// image paired with a pose that no longer describes the same origin.
void InvalidateInFlightPose(const char* reason);
void RunFrameCycle(VkQueue queue, VkDevice device);
// Returns true when the backend owns this present but ran no XR frame, so
// the caller paces the guest itself.
bool NoteHostPresentLocked(VkQueue queue, VkDevice device);
bool CreateSwapchains(std::string* error);
// Recreates the eye swapchains once in the format that copies the guest's
// eye bytes without a gamma change (see PreferredEyeSwapchainFormat).
void MatchSwapchainFormatToEyesLocked();
void DestroySwapchains();
bool EnsureCopyResources(VkDevice device, std::uint32_t queue_family,
std::string* error);
void DestroyCopyResources();
// Records both eye copies (and, when the mirror is enabled, both staging
// readbacks) into one command buffer and submits it once. Only the shared
// memory mirror waits for it; the headset path leaves it to the GPU.
bool CopyEyesIntoSwapchains(VkQueue queue);
void RecordEyeCopiesLocked(VkCommandBuffer command, const VkImage source[2],
const VkImage destination[2]);
void RecordFilteredEyesLocked(VkCommandBuffer command, bool smaa);
// Per-frame GPU work (desktop mirror, eye copies) is submitted without a CPU
// wait. A slot is reused only after its fence signalled; its images let a
// guest destroy wait for the submissions that still read them.
struct AsyncSubmission {
VkCommandBuffer command = VK_NULL_HANDLE;
VkFence fence = VK_NULL_HANDLE;
bool pending = false;
std::uint64_t serial = 0; // bumped per submission
std::vector<VkImage> images;
};
// The shared-memory mirror's readback from the previous frame copy. It is
// published at the next copy, when the GPU has long finished it, so neither
// the headset nor the mirror makes the CPU wait for the frame.
struct MirrorReadback {
AsyncSubmission* slot = nullptr;
std::uint64_t serial = 0;
std::uint64_t frame = 0;
VkFormat format = VK_FORMAT_UNDEFINED;
std::uint32_t width = 0;
std::uint32_t height = 0;
};
void PublishMirrorReadbackLocked();
// vr.anti_aliasing: fxaa. The eye copy becomes one FXAA draw per eye into
// the runtime's swapchain image. Anything it cannot set up falls back to
// the plain copy, which never loses headset output.
struct FxaaState {
VkFormat target_format = VK_FORMAT_UNDEFINED;
VkShaderModule vertex = VK_NULL_HANDLE;
VkShaderModule fragment = VK_NULL_HANDLE;
VkSampler sampler = VK_NULL_HANDLE;
VkDescriptorSetLayout set_layout = VK_NULL_HANDLE;
VkDescriptorPool pool = VK_NULL_HANDLE;
VkDescriptorSet sets[2] = {};
VkPipelineLayout layout = VK_NULL_HANDLE;
VkRenderPass render_pass = VK_NULL_HANDLE;
VkPipeline pipeline = VK_NULL_HANDLE;
// The eye image each set samples, and its view.
VkImage sources[2] = {};
VkImageView source_views[2] = {};
// Per eye, one view and framebuffer per runtime swapchain image.
std::vector<VkImageView> target_views[2];
std::vector<VkFramebuffer> framebuffers[2];
bool failed = false;
};
// vr.anti_aliasing. SMAA shares FXAA's final pass, targets and eye views.
enum class AntiAliasing { kOff, kFxaa, kSmaa };
AntiAliasing AntiAliasingMode() const;
// Pipeline, targets and eye bindings for this frame; false means copy.
bool PrepareFxaaLocked();
bool CreateFxaaPipelineLocked(VkFormat target_format);
bool CreateFullscreenPipelineLocked(VkShaderModule vertex,
VkShaderModule fragment,
VkPipelineLayout layout,
VkRenderPass render_pass,
VkPipeline* pipeline);
void DestroyFxaaTargetsLocked();
void ForgetFxaaSourceLocked(VkImage image);
void DestroyFxaaLocked();
// SMAA: edge detection and blending weights into per-eye images, then
// neighborhood blending into FXAA's swapchain targets.
struct SmaaPass {
VkDescriptorSetLayout set_layout = VK_NULL_HANDLE;
VkPipelineLayout layout = VK_NULL_HANDLE;
VkRenderPass render_pass = VK_NULL_HANDLE;
VkPipeline pipeline = VK_NULL_HANDLE;
VkDescriptorSet sets[2] = {};
};
struct SmaaImage {
VkImage image = VK_NULL_HANDLE;
VkDeviceMemory memory = VK_NULL_HANDLE;
VkImageView view = VK_NULL_HANDLE;
};
struct SmaaState {
std::vector<VkShaderModule> modules;
VkDescriptorPool pool = VK_NULL_HANDLE;
SmaaPass edges, weights, blend;
SmaaImage area, search;
// Per eye, at the eye's extent.
SmaaImage edge_images[2], weight_images[2];
VkFramebuffer edge_framebuffers[2] = {};
VkFramebuffer weight_framebuffers[2] = {};
std::uint32_t width = 0, height = 0;
VkFormat target_format = VK_FORMAT_UNDEFINED;
// The eye views the edge and blend sets were written with.
VkImageView bound_sources[2] = {};
bool failed = false;
};
bool PrepareSmaaLocked();
bool CreateSmaaPipelinesLocked();
bool CreateSmaaImageLocked(VkFormat format, std::uint32_t width,
std::uint32_t height, VkImageUsageFlags usage,
SmaaImage* out);
bool UploadSmaaLookupLocked(SmaaImage* image, std::uint32_t width,
std::uint32_t height, const void* bytes,
std::size_t size);
void DestroySmaaImageLocked(SmaaImage* image);
void DestroySmaaTargetsLocked();
void DestroySmaaLocked();
// The next slot, recorded-ready, or nullptr when its previous submission
// did not finish in time (the device is hung or lost).
AsyncSubmission* NextAsyncSubmissionLocked();
VkResult SubmitAsyncLocked(VkQueue queue, AsyncSubmission* slot,
const VkSubmitInfo& submit);
// Waits for pending submissions reading `image`, or all of them for
// VK_NULL_HANDLE.
void WaitAsyncSubmissionsLocked(VkImage image);
// Semaphore the present of `image_index` waits on, created on first use.
enum class PresentStep { kMirror, kPrepare };
VkSemaphore PresentReadySemaphoreLocked(VkSwapchainKHR swapchain,
std::uint32_t image_index,
PresentStep step);
void DestroyRetiredSemaphoresLocked();
// In-headset keyboard: show/hide from the host's TextBox request, pointer
// input, and trigger masking. Runs inside the controller sync.
void UpdateVirtualKeyboardLocked();
// Rasterizes and uploads the panel when it changed. Returns true when the
// keyboard swapchain holds a released image ready for a quad layer.
bool PrepareVirtualKeyboardLayerLocked(VkQueue queue);
bool UploadVirtualKeyboardVulkan(VkQueue queue, VkImage image);
void DestroyVirtualKeyboardStaging();
void DestroyVirtualKeyboardResources();
bool CaptureEyeEvidence(VkQueue queue, int eye);
ScriptedPoseSample ComputeScriptedPose(std::uint64_t frame) const;
void EnsureMirrorForExtent(std::uint32_t width, std::uint32_t height);
MirrorVkProcs MirrorProcs() const;
VrGraphicsApi graphics_api_ = VrGraphicsApi::kVulkan;
GlesTransport *gles_ = nullptr;
std::uint64_t gl_min_version_ = 0;
std::uint64_t gl_max_version_ = 0;
// Held for a whole XR frame cycle and by every path that can destroy the
// session; always taken before mutex_.
std::mutex frame_mutex_;
mutable std::mutex mutex_;
// The frame cycle's hold on mutex_, so it can release it around
// xrWaitFrame. Only set while NoteHostPresent runs the cycle.
std::unique_lock<std::mutex>* state_lock_ = nullptr;
std::atomic<bool> armed_{false};
std::atomic<bool> recording_{false};
std::atomic<bool> eyes_bound_{false};
std::atomic<bool> session_running_{false};
std::atomic<std::uint64_t> submitted_frames_{0};
std::atomic<std::uint64_t> skipped_frames_{0};
// XR handles are stored as opaque pointers in the header; the implementation
// casts them back. This keeps vulkan-only consumers free of OpenXR headers.
void* xr_instance_ = nullptr;
std::uint64_t xr_system_ = 0;
void* xr_session_ = nullptr;
void* xr_local_space_ = nullptr;
void* xr_view_space_ = nullptr;
void* xr_stage_space_ = nullptr;
std::uint32_t xr_eye_width_[2] = {};
std::uint32_t xr_eye_height_[2] = {};
EyeSwapchain eye_swapchains_[2];
VirtualKeyboard keyboard_;
std::unique_ptr<VirtualKeyboardRasterizer> keyboard_raster_;
EyeSwapchain keyboard_swapchain_;
bool keyboard_swapchain_failed_ = false;
bool keyboard_image_ready_ = false;
std::uint64_t keyboard_uploaded_revision_ = 0;
// A new showKeyboard request may open the panel; Close disarms it until
// Roblox drops that request.
bool keyboard_armed_ = true;
pose::Pose keyboard_head_{};
bool keyboard_head_valid_ = false;
std::vector<std::uint8_t> keyboard_pixels_;
VkBuffer keyboard_staging_ = VK_NULL_HANDLE;
VkDeviceMemory keyboard_staging_memory_ = VK_NULL_HANDLE;
void* keyboard_staging_mapped_ = nullptr;
int xr_blend_mode_ = 0;
// Runtime name captured at xrCreateInstance so the bind-time diagnostics line
// can report it without re-entering runtime_name() under the held mutex.
std::string runtime_name_;
bool xr_instance_used_ = false;
// Libraries the runtime's first instance loaded; later instances load them
// with RTLD_DEEPBIND (see EnsureXrInstanceLocked).
std::vector<std::string> runtime_libraries_;
std::string user_manifest_;
std::string runtime_manifest_;
VkInstance vk_instance_ = VK_NULL_HANDLE;
VkDevice vk_device_ = VK_NULL_HANDLE;
VkPhysicalDevice vk_physical_device_ = VK_NULL_HANDLE;
std::uint32_t vk_queue_family_ = 0;
VkQueue vk_graphics_queue_ = VK_NULL_HANDLE;
std::vector<std::uint32_t> device_queue_families_;
void* vk_loader_ = nullptr;
struct VkProcs;
VkProcs* vk_ = nullptr;
VkCommandPool copy_pool_ = VK_NULL_HANDLE;
// Synchronous one-off work: keyboard uploads, evidence, present fallback.
VkCommandBuffer copy_command_ = VK_NULL_HANDLE;
VkFence copy_fence_ = VK_NULL_HANDLE;
// Two submissions per frame, so three frames may be in flight.
static constexpr std::size_t kAsyncSubmissions = 6;
std::array<AsyncSubmission, kAsyncSubmissions> async_{};
std::size_t next_async_ = 0;
MirrorReadback mirror_readback_{};
FxaaState fxaa_{};
SmaaState smaa_{};
// The mirror's signal for this present, which PrepareHostPresent passes on.
VkSemaphore mirror_ready_ = VK_NULL_HANDLE;
// Semaphores of destroyed desktop swapchains. A present may still wait on
// one, so they are destroyed only once the device is idle.
std::vector<VkSemaphore> retired_semaphores_;
std::unordered_map<VkFormat, VkFormatFeatureFlags> blit_features_;
// Optional desktop two-eye mirror (MOCKTAIL_VR_MIRROR_SHM). Publishes the
// latest complete pair through shared memory; inert when unset.
VrMirror mirror_;
std::unordered_map<VkImage, ImageRecord> images_;
struct DesktopSwapchain {
VkExtent2D extent;
VkFormat format;
std::vector<VkImage> images;
VkSharingMode sharing_mode = VK_SHARING_MODE_EXCLUSIVE;
std::vector<std::uint32_t> families;
bool converted_to_concurrent = false;
// Two per image, see PresentReadySemaphoreLocked.
std::vector<VkSemaphore> ready;
};
std::unordered_map<VkSwapchainKHR, DesktopSwapchain> desktop_swapchains_;
std::unordered_map<VkImageView, VkImage> image_views_;
std::unordered_map<VkFramebuffer, std::vector<VkImageView>> framebuffer_views_;
std::unordered_map<VkFramebuffer, VkRenderPass> framebuffer_render_pass_;
std::unordered_map<VkRenderPass, std::vector<VkImageLayout>> render_pass_final_layouts_;
std::unordered_map<VkQueue, std::uint32_t> queue_families_;
struct CommandRecord {
VkCommandPool pool = VK_NULL_HANDLE;
std::uint32_t family = UINT32_MAX;
std::vector<std::pair<VkImage, std::uint64_t>> eye_images;
};
std::unordered_map<VkCommandPool, std::uint32_t> command_pool_families_;
std::unordered_map<VkCommandBuffer, CommandRecord> command_records_;
std::unordered_map<VkImage, VkQueue> eye_render_queues_;
std::unordered_map<VkImage, std::uint64_t> eye_render_frames_;
std::uint64_t image_order_ = 0;
EyeBinding eyes_[2];
// Frame cycle state (render thread only).
bool frame_open_ = false;
bool frame_should_render_ = false;
// A running session is hidden in SYNCHRONIZED; shouldRender still governs rendering.
bool visibility_lost_ = false;
bool session_recovery_pending_ = false;
std::uint64_t next_session_retry_ns_ = 0;
// Consecutive failed session recoveries, for backoff and for deciding when
// the XrInstance itself must be recreated (e.g. after a runtime restart).
std::uint32_t session_recovery_failures_ = 0;
bool xr_instance_stale_ = false;
bool present_sync_failure_logged_ = false;
bool gles_attach_failed_ = false;
// Runtime swapchain formats, and the format chosen once the guest eye
// format is known (VK_FORMAT_UNDEFINED until then).
std::vector<std::int64_t> xr_swapchain_formats_;
VkFormat eye_swapchain_format_ = VK_FORMAT_UNDEFINED;
VkFormat eye_format_checked_ = VK_FORMAT_UNDEFINED;
std::uint64_t frame_display_time_ = 0;
// MOCKTAIL_VR_UNPACED benchmark mode: guest presents before this monotonic
// time leave the open XR frame alone instead of blocking in xrWaitFrame.
std::uint64_t frame_due_ns_ = 0;
struct ViewState {
float position[3];
float orientation[4];
float fov[4]; // angleLeft, angleRight, angleUp, angleDown (radians)
bool valid;
};
ViewState frame_views_[2] = {};
struct PoseStorage {
float position[3] = {0.f, 0.f, 0.f};
float orientation[4] = {0.f, 0.f, 0.f, 1.f};
};
PoseStorage frame_view_poses_[2];
bool frame_views_valid_ = false;
// One-shot guard so a canted-view runtime reports the rejection reason once
// instead of once per frame per eye.
bool projection_rejection_logged_ = false;
mutable ScriptedPoseSample published_pose_;
std::uint64_t xr_frame_counter_ = 0;
std::unordered_map<void*, std::uint64_t> applied_poses_;
};
// Process-wide single backend owned by main. Adapter hooks resolve these
// exported C symbols; they are inert while no backend is armed.
OpenXrBackend* ActiveVrBackend();
// Paths of the shared objects mapped into the process.
std::vector<std::string> LoadedSharedObjects();
// Paths mapped now that were not in `before`.
std::vector<std::string> SharedObjectsLoadedSince(
const std::vector<std::string>& before);
// Holds the runtime's libraries open with RTLD_DEEPBIND while the OpenXR
// loader loads the runtime, so its Vulkan calls bind to its own libvulkan
// dependency instead of the Vulkan adapter's exported hooks. Released after
// the loader holds its own reference, so the runtime still unloads with its
// instance.
class DeepBoundLibraries {
public:
explicit DeepBoundLibraries(const std::vector<std::string>& paths);
~DeepBoundLibraries();
DeepBoundLibraries(const DeepBoundLibraries&) = delete;
DeepBoundLibraries& operator=(const DeepBoundLibraries&) = delete;
private:
std::vector<void*> handles_;
};
} // namespace mocktail::vr
#endif // MOCKTAIL_VR_OPENXR_BACKEND_H_
+34
View File
@@ -0,0 +1,34 @@
#ifndef MOCKTAIL_VR_OPENXR_PREVIEW_H_
#define MOCKTAIL_VR_OPENXR_PREVIEW_H_
#include <array>
#include <cstdint>
#include <string>
namespace mocktail::vr {
struct PreviewOptions {
// Zero keeps the scene open until the runtime or user requests exit.
std::uint32_t frame_limit = 0;
};
struct PreviewResult {
bool success = false;
std::string message;
std::uint64_t frames = 0;
std::uint64_t rendered_frames = 0;
std::uint64_t tracked_frames = 0;
std::array<float, 3> head_position{};
std::array<float, 4> head_orientation{0, 0, 0, 1};
};
// Runs a native Vulkan/OpenXR scene with a panel fixed in LOCAL space. The
// compositor projects it into both eyes. Roblox images/input are not connected.
// Call on the main thread: SIGINT/SIGTERM request a graceful session exit, and
// their previous handlers are restored before returning.
PreviewResult RunOpenXrPreview(const PreviewOptions& options = {});
std::string FormatPreviewResult(const PreviewResult& result);
} // namespace mocktail::vr
#endif // MOCKTAIL_VR_OPENXR_PREVIEW_H_
+52
View File
@@ -0,0 +1,52 @@
#ifndef MOCKTAIL_VR_OPENXR_PROBE_H_
#define MOCKTAIL_VR_OPENXR_PROBE_H_
#include <cstdint>
#include <string>
#include <vector>
namespace mocktail::vr {
enum class ProbeStatus {
kReady,
kBuildDisabled,
kRuntimeUnavailable,
kHeadsetUnavailable,
kIncompatibleRuntime,
kError,
};
struct ViewConfiguration {
std::uint32_t width = 0;
std::uint32_t height = 0;
std::uint32_t samples = 0;
};
struct ProbeReport {
ProbeStatus status = ProbeStatus::kError;
std::string message;
std::string sdk_version;
std::string runtime_name;
std::string runtime_version;
std::string system_name;
std::vector<std::string> extensions;
std::vector<ViewConfiguration> views;
bool orientation_tracking = false;
bool position_tracking = false;
std::string minimum_vulkan_version;
std::string maximum_vulkan_version;
};
// Native host discovery only. Does not load Roblox, change the active runtime,
// create a graphics device/session, or claim that VR rendering is implemented.
ProbeReport ProbeOpenXr();
std::string FormatProbeReport(const ProbeReport& report);
int ProbeExitCode(ProbeStatus status);
// False in builds configured without MOCKTAIL_ENABLE_VR; --vr fails closed
// early in that case instead of starting Roblox without VR support.
bool IsOpenXrSupportCompiled();
} // namespace mocktail::vr
#endif // MOCKTAIL_VR_OPENXR_PROBE_H_
@@ -0,0 +1,130 @@
#ifndef MOCKTAIL_VR_ROBLOX_VR_DEVICE_BRIDGE_H_
#define MOCKTAIL_VR_ROBLOX_VR_DEVICE_BRIDGE_H_
#include <atomic>
#include <cstdint>
#include <mutex>
#include <string>
#include <string_view>
#include "compat/build_profile.h"
#include "mocktail/status.h"
namespace mocktail::vr {
namespace internal {
struct VrBridgeTestAccess;
struct VrBuildContract;
}
const compat::VrDebugDeviceBridgeProfile* FindRobloxVrDeviceProfile(
std::string_view build_id);
// Human-readable list of the Roblox builds VR supports, e.g.
// "2998 (2.736.1408) and 3092 (2.738.1397)", for error messages.
std::string DescribeSupportedVrBuilds();
// Experimental native stereo bring-up for the exact Build-ID-scoped
// RBX::Graphics::DebugDeviceVR object of the guest client.
//
// The bridge interposes the state and eye getters of the exact guest build.
// Resource readiness is checked on the live object during each state call;
// initialization never dereferences cached guest object/device pointers. The
// guest owns resource destruction. Recreated objects and reused addresses
// therefore need no cache invalidation. Initialization happens only on a thread
// observed presenting.
class RobloxVrDeviceBridge final {
public:
using StateGetterFn = void *(*)(void *result_buffer, void *device_object);
using EyeGetterFn = void *(*)(void *device_object, int eye_index);
using EyeInitializerFn = void (*)(void *device_object, void *graphics_device);
RobloxVrDeviceBridge() = default;
~RobloxVrDeviceBridge();
RobloxVrDeviceBridge(const RobloxVrDeviceBridge &) = delete;
RobloxVrDeviceBridge &operator=(const RobloxVrDeviceBridge &) = delete;
// Arms the bridge for an exact-build profile that carries
// vr_debug_device_bridge metadata. Claims the process-wide bridge slot.
Status Install(const compat::BuildProfile &profile);
// Validates machine contracts at the loaded image base and interposes the
// two DebugDeviceVR vtable slots. Called once, after libroblox relocations
// and before any guest startup code runs.
Status Activate(std::uintptr_t image_base);
// Restores the interposed vtable slots and disarms. Safe to call twice.
void Shutdown();
bool installed() const { return installed_; }
bool active() const { return active_.load(std::memory_order_acquire); }
// Render-thread notification from the Vulkan adapter's host present path.
void NoteHostPresent();
// Guest-side hooks invoked by the interposed vtable stubs.
void OnHapticsCall(void* device_object, int hand, float amplitude);
// HapticService:SetMotor on Gamepad1 LeftHand (0) / RightHand (1); the
// level holds until the game sets it again.
void OnSetMotorCall(int hand, float amplitude);
void OnPointerFrameCall(void* result, void* service);
void OnStateGetterCall(void *result_buffer, void *device_object);
void OnEyeGetterCall(void *device_object, int eye_index, void *framebuffer);
StateGetterFn original_state_getter() const {
return original_state_getter_.load(std::memory_order_acquire);
}
EyeGetterFn original_eye_getter() const {
return original_eye_getter_.load(std::memory_order_acquire);
}
// Cumulative initialization diagnostics (not a count of live devices).
std::uintptr_t image_base() const {
return image_base_.load(std::memory_order_acquire);
}
int captured_device_count() const;
int initialized_device_count() const;
std::uint64_t present_count() const {
return present_count_.load(std::memory_order_relaxed);
}
std::uint64_t eye_getter_call_count(int eye_index) const;
private:
friend struct internal::VrBridgeTestAccess;
static constexpr std::uint64_t kEyeGetterSummaryInterval = 600;
bool ValidateObjectHeader(void *self) const;
void *ValidatedGraphicsDevice(void *self) const;
void TryInitializeEyes(void *self);
bool RestoreVtableLocked();
mutable std::mutex mutex_;
compat::VrDebugDeviceBridgeProfile profile_{};
const internal::VrBuildContract* contract_ = nullptr;
bool installed_ = false;
std::atomic<bool> active_{false};
std::atomic<std::uintptr_t> image_base_{0};
std::atomic<std::uintptr_t> image_size_{0};
std::uintptr_t *vtable_ = nullptr;
std::uintptr_t original_haptic_slot_ = 0;
std::uintptr_t original_state_slot_ = 0;
std::uintptr_t original_eye_slot_ = 0;
std::atomic<StateGetterFn> original_state_getter_{nullptr};
std::atomic<EyeGetterFn> original_eye_getter_{nullptr};
std::atomic<std::uint64_t> present_thread_{0};
std::atomic<std::uint64_t> present_count_{0};
std::atomic<bool> flag_warning_logged_{false};
std::atomic<int> captured_count_{0};
std::atomic<int> initialized_count_{0};
mutable std::atomic<std::uint64_t> eye_call_totals_[2] = {};
};
// Process-wide single active bridge, mirroring the audio output-device
// bridge ownership model. Install claims the slot; the destructor releases
// it. legacy_runtime forwards the loaded libroblox image base through
// NotifyRobloxVrImageLoaded; it is a successful no-op while no bridge is
// armed, and fail-closed once one is.
Status NotifyRobloxVrImageLoaded(std::uintptr_t image_base);
} // namespace mocktail::vr
#endif // MOCKTAIL_VR_ROBLOX_VR_DEVICE_BRIDGE_H_
+191
View File
@@ -0,0 +1,191 @@
#ifndef MOCKTAIL_VR_VIRTUAL_KEYBOARD_H_
#define MOCKTAIL_VR_VIRTUAL_KEYBOARD_H_
#include <cstdint>
#include <string>
#include <vector>
#include "mocktail/vr/vr_pose_math.h"
namespace mocktail::vr {
// Host side of the in-headset keyboard. The window module owns Roblox's
// TextBox focus and the SDL event path; main wires these callbacks so the VR
// library never links the window library. Every callback may be called from
// the render thread and must be thread-safe.
struct VirtualKeyboardHost {
void* context = nullptr;
// True while Roblox has asked for an on-screen keyboard (showKeyboard).
bool (*input_requested)(void* context) = nullptr;
// Commits UTF-8 text exactly like an SDL_EVENT_TEXT_INPUT.
void (*commit_text)(void* context, const char* utf8) = nullptr;
// Presses and releases one SDL scancode exactly like a physical key.
void (*press_key)(void* context, std::uint32_t scancode) = nullptr;
};
// Installs the process-wide host. Passing nullptr clears it.
void SetVirtualKeyboardHost(const VirtualKeyboardHost* host);
VirtualKeyboardHost CurrentVirtualKeyboardHost();
// The focused TextBox's current contents, shown in the panel's text field.
// The desktop draws TextBox text as a window overlay that never reaches the
// eye images, so the headset would otherwise show an empty box. Safe from any
// thread; the text is sensitive and is never logged.
struct VirtualKeyboardText {
std::string utf8;
int cursor_utf16 = 0;
bool password = false;
std::uint64_t revision = 0;
};
void SetVirtualKeyboardText(const char* utf8, std::size_t size,
int cursor_utf16, bool password);
void ClearVirtualKeyboardText();
VirtualKeyboardText CurrentVirtualKeyboardText();
enum class VirtualKeyAction {
kText, // commits text, or shift_text under Shift/Caps Lock
kBackspace,
kEnter,
kTab,
kShift, // one-shot
kCapsLock,
kSpace,
kClose, // Escape: leaves the TextBox and hides the panel
};
struct VirtualKey {
std::string label;
std::string text; // committed text for kText
std::string shift_text; // committed under Shift (Caps Lock for letters)
VirtualKeyAction action = VirtualKeyAction::kText;
// Panel UV rectangle, origin top-left, both axes 0..1.
float u0 = 0.f, v0 = 0.f, u1 = 0.f, v1 = 0.f;
};
// One controller's pointing state for a frame. Positions are metres in the
// OpenXR LOCAL space; the ray points along the aim pose's -Z axis.
struct VirtualKeyboardPointer {
bool valid = false;
pose::Pose aim{};
float trigger = 0.f; // 0..1
};
// What the keyboard asks the host to do after an Update.
struct VirtualKeyboardOutput {
std::vector<std::string> text; // committed in order
std::vector<std::uint32_t> scancodes; // pressed in order, after text
};
// Ray/quad intersection. The quad is centred on `quad` with its face along
// +Z, `width` x `height` metres. Returns false when the ray misses or points
// away. u/v are panel UVs (origin top-left).
bool IntersectVirtualKeyboardPanel(const pose::Pose& quad, float width,
float height, const pose::Pose& aim,
float* u, float* v, float* distance);
// Pure keyboard state machine: layout, paging, shift, hover, trigger edges and
// backspace auto-repeat. No OpenXR, Vulkan or SDL types, so it is unit-tested
// directly.
class VirtualKeyboard {
public:
// A text field over 15 x 5 normal-key units, 4 cm per unit.
static constexpr float kPanelWidthMetres = 0.60f;
static constexpr float kPanelHeightMetres = 0.248f;
// Texture aspect matches the panel so keys are not stretched.
static constexpr int kTextureWidth = 1500;
static constexpr int kTextureHeight = 620;
// Top share of the panel holding the text field; keys fill the rest.
static constexpr float kFieldFraction = 120.f / 620.f;
VirtualKeyboard();
// Opens the panel in front of `head` (LOCAL space), below eye level and
// tilted up toward the viewer. Resets Shift and hover; keeps Caps Lock.
void Show(const pose::Pose& head);
void Hide();
bool visible() const { return visible_; }
const pose::Pose& panel_pose() const { return panel_; }
// Text shown in the field. Bumps the revision only when it changed.
void SetText(const VirtualKeyboardText& text);
const std::string& text() const { return text_.utf8; }
int text_cursor_utf16() const { return text_.cursor_utf16; }
bool text_password() const { return text_.password; }
// Processes one frame of both pointers. now_ns is monotonic.
VirtualKeyboardOutput Update(std::uint64_t now_ns,
const VirtualKeyboardPointer (&pointers)[2]);
// True when this hand's trigger belongs to the keyboard this frame (aimed
// at the panel, or a press that began on it is still held, including after
// the panel hid mid-press). The backend withholds that trigger from Roblox
// so a key press is not also a click.
bool CapturesTrigger(int hand) const;
// Current layout, with Shift/Caps Lock applied to the labels.
const std::vector<VirtualKey>& keys() const;
// -1 when the hand is not over a key.
int hovered_key(int hand) const { return hands_[hand & 1].hovered; }
int pressed_key(int hand) const { return hands_[hand & 1].pressed; }
// Pointer position on the panel, when the hand is over it.
bool cursor(int hand, float* u, float* v) const;
bool shifted() const { return shift_; }
bool caps_lock() const { return caps_lock_; }
// Advances whenever anything visible changes; the backend re-rasterizes
// and re-uploads only when this moves.
std::uint64_t revision() const { return revision_; }
private:
struct Hand {
int hovered = -1;
int pressed = -1; // key under an active press, -1 otherwise
bool trigger_down = false;
bool captured = false;
bool over_panel = false;
float u = 0.f, v = 0.f;
std::uint64_t repeat_at_ns = 0;
};
void RebuildLayout();
int KeyAt(float u, float v) const;
std::string Output(const VirtualKey& key) const;
void Activate(const VirtualKey& key, VirtualKeyboardOutput* output);
void Touch() { ++revision_; }
bool visible_ = false;
bool shift_ = false;
bool caps_lock_ = false;
pose::Pose panel_{};
VirtualKeyboardText text_;
Hand hands_[2];
// A press the keyboard owned when it hid; held until that trigger releases.
bool release_pending_[2] = {false, false};
std::uint64_t cursor_drawn_ns_ = 0;
std::vector<VirtualKey> keys_;
std::uint64_t revision_ = 1;
};
// Draws the keyboard into a tightly packed RGBA8 buffer
// (VirtualKeyboard::kTextureWidth x kTextureHeight, sRGB-encoded, opaque).
// Glyphs come from SDL3_ttf with fontconfig's sans fallback chain and are
// cached per label, so redraws after the first are plain fills and blits.
class VirtualKeyboardRasterizer {
public:
VirtualKeyboardRasterizer();
~VirtualKeyboardRasterizer();
VirtualKeyboardRasterizer(const VirtualKeyboardRasterizer&) = delete;
VirtualKeyboardRasterizer& operator=(const VirtualKeyboardRasterizer&) =
delete;
// Returns false when no font could be opened; keys still draw, unlabelled.
bool Draw(const VirtualKeyboard& keyboard, std::vector<std::uint8_t>* rgba);
private:
struct Impl;
Impl* impl_ = nullptr;
};
} // namespace mocktail::vr
#endif // MOCKTAIL_VR_VIRTUAL_KEYBOARD_H_
+105
View File
@@ -0,0 +1,105 @@
#ifndef MOCKTAIL_VR_MIRROR_H_
#define MOCKTAIL_VR_MIRROR_H_
#include <vulkan/vulkan.h>
#include <cstdint>
#include <string>
#include "mocktail/vr/vr_mirror_shm.h"
namespace mocktail::vr {
// The subset of device/instance procs the mirror needs, supplied by the XR
// backend from its already-resolved guest-device table. Keeping this explicit
// avoids a second proc table and keeps the mirror independently testable.
struct MirrorVkProcs {
PFN_vkCreateBuffer CreateBuffer = nullptr;
PFN_vkDestroyBuffer DestroyBuffer = nullptr;
PFN_vkGetBufferMemoryRequirements GetBufferMemoryRequirements = nullptr;
PFN_vkAllocateMemory AllocateMemory = nullptr;
PFN_vkFreeMemory FreeMemory = nullptr;
PFN_vkBindBufferMemory BindBufferMemory = nullptr;
PFN_vkMapMemory MapMemory = nullptr;
PFN_vkUnmapMemory UnmapMemory = nullptr;
PFN_vkCmdCopyImageToBuffer CmdCopyImageToBuffer = nullptr;
PFN_vkCmdPipelineBarrier CmdPipelineBarrier = nullptr;
PFN_vkGetPhysicalDeviceMemoryProperties GetPhysicalDeviceMemoryProperties =
nullptr;
};
// Producer side of the desktop two-eye mirror. Maps a POSIX shared-memory
// segment (created with the name from MOCKTAIL_VR_MIRROR_SHM) and publishes
// the latest complete eye pair under a seqlock. It owns two persistent,
// bounded host-visible staging buffers reused across frames; the GPU->staging
// copies are recorded into the caller's single batched command buffer so the
// mirror adds no extra queue submission or fence wait.
//
// The mirror never touches the filesystem on the frame path and keeps only the
// newest pair (one latest slot), so a slow consumer drops rather than queues
// old frames.
class VrMirror {
public:
VrMirror() = default;
~VrMirror();
VrMirror(const VrMirror&) = delete;
VrMirror& operator=(const VrMirror&) = delete;
bool enabled() const { return mapping_ != nullptr; }
// Opens the shared segment. Returns false (and stays disabled) when the name
// is empty or mapping fails; the XR path then runs without a mirror.
bool Open(const std::string& shm_name, std::uint32_t width,
std::uint32_t height, std::string* error);
void Close();
// Ensures the two staging buffers match width*height*4 bytes on this device.
// Allocates once; a size/device change recreates them. Existing contents are
// discarded on resize.
bool EnsureStaging(const MirrorVkProcs& vk, VkDevice device,
VkPhysicalDevice physical, std::uint32_t width,
std::uint32_t height, std::string* error);
void DestroyStaging(const MirrorVkProcs& vk);
// Records a source-eye -> staging-buffer copy for one eye into cmd, adding
// the layout barriers around it. The source is returned to final_layout so
// the next guest render pass sees it unchanged.
void RecordEyeCopy(const MirrorVkProcs& vk, VkCommandBuffer cmd, int eye,
VkImage source, VkImageLayout final_layout,
std::uint32_t width, std::uint32_t height);
// After the batched fence wait completed, copies both staging buffers into
// the shared segment and bumps the seqlock with the frame id and a monotonic
// timestamp. No-op when staging does not cover width*height.
void Publish(std::uint64_t frame, VkFormat format, std::uint32_t width,
std::uint32_t height, std::uint64_t now_ns);
std::uint64_t published_pairs() const { return published_pairs_; }
std::uint32_t staging_width() const { return staging_width_; }
std::uint32_t staging_height() const { return staging_height_; }
const void* mapping() const { return mapping_; }
// Test seam: publish a pair from caller-supplied staging bytes without any
// Vulkan resources, exercising the same seqlock/segment write as the GPU
// path. Both buffers must hold width*height*4 bytes.
void PublishFromBytesForTest(std::uint64_t frame, VkFormat format,
std::uint32_t width, std::uint32_t height,
std::uint64_t now_ns, const void* eye0,
const void* eye1);
private:
void* mapping_ = nullptr;
std::uint64_t mapping_bytes_ = 0;
std::string shm_name_;
VkBuffer staging_[2] = {VK_NULL_HANDLE, VK_NULL_HANDLE};
VkDeviceMemory staging_memory_[2] = {VK_NULL_HANDLE, VK_NULL_HANDLE};
void* staging_map_[2] = {nullptr, nullptr};
std::uint32_t staging_width_ = 0;
std::uint32_t staging_height_ = 0;
VkDevice staging_device_ = VK_NULL_HANDLE;
std::uint64_t published_pairs_ = 0;
};
} // namespace mocktail::vr
#endif // MOCKTAIL_VR_MIRROR_H_
+42
View File
@@ -0,0 +1,42 @@
#ifndef MOCKTAIL_VR_MIRROR_SHM_H_
#define MOCKTAIL_VR_MIRROR_SHM_H_
#include <cstdint>
namespace mocktail::vr {
// Shared-memory transport for the desktop two-eye mirror. The producer (XR
// backend, inside the client process) publishes the latest complete eye pair
// under a seqlock; the consumer (desktop viewer) reads the newest pair without
// any per-frame filesystem transport, image encode/decode, or allocation.
//
// Layout is fixed and little-endian.
// sizeof(VrMirrorHeader) == kVrMirrorHeaderBytes.
struct VrMirrorHeader {
std::uint32_t magic; // kVrMirrorMagic
std::uint32_t header_bytes; // kVrMirrorHeaderBytes (pixel area offset)
std::uint64_t seq; // seqlock: odd while writing, even when stable
std::uint64_t frame; // XR frame id the pair belongs to
std::uint32_t width; // eye width in pixels
std::uint32_t height; // eye height in pixels
std::uint32_t format; // VkFormat of the source eye images
std::uint32_t flags; // bit0: a complete, valid pair is present
std::uint64_t producer_ns; // CLOCK_MONOTONIC publish timestamp
std::uint64_t published_pairs;
std::uint64_t reserved;
};
inline constexpr std::uint32_t kVrMirrorMagic = 0x3152564D; // 'MVR1'
inline constexpr std::uint32_t kVrMirrorHeaderBytes = 64;
inline constexpr std::uint32_t kVrMirrorFlagValid = 1u << 0;
// Total mapped bytes for a given eye extent (header + two RGBA/BGRA images).
inline std::uint64_t VrMirrorBytes(std::uint32_t width, std::uint32_t height) {
return static_cast<std::uint64_t>(kVrMirrorHeaderBytes) +
2ull * static_cast<std::uint64_t>(width) *
static_cast<std::uint64_t>(height) * 4ull;
}
} // namespace mocktail::vr
#endif // MOCKTAIL_VR_MIRROR_SHM_H_
+102
View File
@@ -0,0 +1,102 @@
#ifndef MOCKTAIL_VR_PERF_H_
#define MOCKTAIL_VR_PERF_H_
#include <cstdint>
#include <string>
#include <vector>
namespace mocktail::vr::perf {
// Opt-in, low-overhead stage timing and counter instrumentation. Collection is
// compiled out at runtime unless MOCKTAIL_VR_PERF is set, so the steady-state
// path only pays a single cached boolean test per record.
bool Enabled();
enum class Stage {
kFrameCycle,
kXrWaitFrame,
kXrBeginFrame,
kXrLocateViews,
kSwapchainAcquire,
kSwapchainWait,
kCopyRecord,
kCopySubmit,
kCopyFenceWait,
kMirrorPublish,
kEvidence,
kControllerSync,
kDesktopMirror,
kPresentPrepare,
kPresentInterval,
kStageCount,
};
enum class Counter {
kPresents,
kFramesBegun,
kFramesSubmitted,
kFramesSkipped,
kSkipShouldRender,
kSkipPoseInvalid,
kSkipEyesUnbound,
kSkipEyeFrameMismatch,
kSubmits,
kFenceWaits,
kMirrorPairs,
kMirrorDrops,
kSkipVisibilityLost,
kReferenceSpaceChanges,
kVisibilityLost,
kVisibilityGained,
kFramesInvalidated,
kControllerSyncs,
kCounterCount,
};
const char* StageName(Stage stage);
const char* CounterName(Counter counter);
// Bounded reservoir per stage: the most recent N samples are kept and ranked
// only when a report is emitted (once per interval), never on the frame path.
class Collector {
public:
static constexpr std::size_t kReservoir = 4096;
static constexpr std::uint64_t kIntervalNs = 1'000'000'000ULL;
void Record(Stage stage, std::uint64_t nanos);
void Count(Counter counter, std::uint64_t delta = 1);
// Emits one distribution line per active stage plus a counter line when at
// least kIntervalNs elapsed since the previous emission. Cheap no-op
// otherwise. now_ns is a CLOCK_MONOTONIC timestamp supplied by the caller so
// the collector stays clock-agnostic and testable.
void EmitIfDue(std::uint64_t now_ns, const char* context);
// Test/inspection accessors.
std::uint64_t counter(Counter counter_id) const;
std::vector<std::uint64_t> Percentiles(Stage stage) const; // p50,p95,p99,max
std::size_t Samples(Stage stage) const;
void Reset();
private:
struct StageStat {
std::vector<std::uint64_t> samples; // ring, bounded by kReservoir
std::size_t head = 0;
std::size_t count = 0;
std::uint64_t total_ns = 0;
};
StageStat stages_[static_cast<std::size_t>(Stage::kStageCount)]{};
std::uint64_t counters_[static_cast<std::size_t>(Counter::kCounterCount)]{};
std::uint64_t last_emit_ns_ = 0;
std::uint64_t window_start_ns_ = 0;
};
// Process-wide collector used by the XR backend frame cycle.
Collector& ProcessCollector();
// Monotonic nanoseconds (CLOCK_MONOTONIC), 0 on failure.
std::uint64_t NowNs();
} // namespace mocktail::vr::perf
#endif // MOCKTAIL_VR_PERF_H_
+217
View File
@@ -0,0 +1,217 @@
#ifndef MOCKTAIL_VR_POSE_MATH_H_
#define MOCKTAIL_VR_POSE_MATH_H_
#include <algorithm>
#include <cmath>
#include <cstdint>
namespace mocktail::vr::pose {
// Coordinate spaces and units used across the whole camera path, documented so
// every stage is auditable:
//
// OpenXR head/views metres, right-handed, +Y up, -Z forward, LOCAL space
// -> published pose metres, quaternion (x, y, z, w), unchanged units
// -> guest state metres; the exact-build native helper converts metres
// to studs itself (factor 10/3). This layer must never
// pre-apply that scale.
// -> camera engine composes
// out.rotation = base.rotation * head.rotation
// out.position = base.position
// + base.rotation * head.position * HeadScale
// where HeadScale is an engine value distinct from the
// metres-to-studs conversion factor.
// -> render one pass per eye using the per-eye offset/FOV
// -> XR submission metres again, the very pose used to render the pair.
struct Vec3 {
float x = 0.f, y = 0.f, z = 0.f;
};
struct Quat {
float x = 0.f, y = 0.f, z = 0.f, w = 1.f;
};
struct Pose {
Vec3 position{};
Quat orientation{};
};
inline constexpr float kPi = 3.14159265358979323846f;
inline Quat QuatMultiply(const Quat& a, const Quat& b) {
return Quat{
a.w * b.x + a.x * b.w + a.y * b.z - a.z * b.y,
a.w * b.y - a.x * b.z + a.y * b.w + a.z * b.x,
a.w * b.z + a.x * b.y - a.y * b.x + a.z * b.w,
a.w * b.w - a.x * b.x - a.y * b.y - a.z * b.z,
};
}
inline Quat QuatConjugate(const Quat& q) { return Quat{-q.x, -q.y, -q.z, q.w}; }
inline float QuatNorm(const Quat& q) {
return std::sqrt(q.x * q.x + q.y * q.y + q.z * q.z + q.w * q.w);
}
// Returns false for a non-finite or near-zero quaternion; the output is left
// untouched in that case so callers never publish a fabricated rotation.
inline bool QuatNormalize(const Quat& in, Quat* out) {
if (out == nullptr) {
return false;
}
if (!std::isfinite(in.x) || !std::isfinite(in.y) || !std::isfinite(in.z) ||
!std::isfinite(in.w)) {
return false;
}
const float norm = QuatNorm(in);
if (!(norm > 1e-6f) || !std::isfinite(norm)) {
return false;
}
const float inverse = 1.f / norm;
*out = Quat{in.x * inverse, in.y * inverse, in.z * inverse, in.w * inverse};
return true;
}
// q and -q denote the same rotation. Used to prove the path is sign-invariant.
inline bool QuatSameRotation(const Quat& a, const Quat& b, float tolerance) {
const float dot = a.x * b.x + a.y * b.y + a.z * b.z + a.w * b.w;
return std::abs(std::abs(dot) - 1.f) <= tolerance;
}
inline Vec3 QuatRotate(const Quat& q, const Vec3& v) {
// t = 2 * cross(q.xyz, v); v' = v + q.w * t + cross(q.xyz, t)
const Vec3 u{q.x, q.y, q.z};
const Vec3 cross1{u.y * v.z - u.z * v.y, u.z * v.x - u.x * v.z,
u.x * v.y - u.y * v.x};
const Vec3 t{2.f * cross1.x, 2.f * cross1.y, 2.f * cross1.z};
const Vec3 cross2{u.y * t.z - u.z * t.y, u.z * t.x - u.x * t.z,
u.x * t.y - u.y * t.x};
return Vec3{v.x + q.w * t.x + cross2.x, v.y + q.w * t.y + cross2.y,
v.z + q.w * t.z + cross2.z};
}
// result = a * b in SE(3): apply b first, then a.
inline Pose PoseMultiply(const Pose& a, const Pose& b) {
Pose result;
result.orientation = QuatMultiply(a.orientation, b.orientation);
const Vec3 rotated = QuatRotate(a.orientation, b.position);
result.position = Vec3{a.position.x + rotated.x, a.position.y + rotated.y,
a.position.z + rotated.z};
return result;
}
inline Pose PoseInverse(const Pose& p) {
Pose result;
result.orientation = QuatConjugate(p.orientation);
const Vec3 neg{-p.position.x, -p.position.y, -p.position.z};
result.position = QuatRotate(result.orientation, neg);
return result;
}
// Rotation for a head turned by yaw about +Y then pitched about +X, matching
// the scripted diagnostic motion. Quaternion order is (x, y, z, w).
inline Quat QuatFromYawPitch(float yaw, float pitch) {
const float cy = std::cos(yaw * 0.5f), sy = std::sin(yaw * 0.5f);
const float cp = std::cos(pitch * 0.5f), sp = std::sin(pitch * 0.5f);
return Quat{cy * sp, sy * cp, -sy * sp, cy * cp};
}
inline Quat QuatFromAxisAngle(const Vec3& axis, float angle) {
const float half = angle * 0.5f;
const float s = std::sin(half);
return Quat{axis.x * s, axis.y * s, axis.z * s, std::cos(half)};
}
// Per-eye FOV as the runtime reports it: angleLeft, angleRight, angleUp,
// angleDown in radians, each signed relative to the eye's forward axis.
struct EyeFov {
float angle_left = 0.f;
float angle_right = 0.f;
float angle_up = 0.f;
float angle_down = 0.f;
};
// The guest state stores projection tangents, not angles. The 2998 layout is
// up, down, left, right in that order; down/left are negated because the
// guest's tangent space measures them in the opposite direction. Getting this
// order or a sign wrong flips or shears the image, so it is pinned by test.
struct ProjectionTangents {
float up = 0.f;
float down = 0.f;
float left = 0.f;
float right = 0.f;
};
inline ProjectionTangents FovToTangents(const EyeFov& fov) {
return ProjectionTangents{std::tan(fov.angle_up), std::tan(-fov.angle_down),
std::tan(-fov.angle_left), std::tan(fov.angle_right)};
}
inline constexpr float kMaxEyeFovAngle = 1.56f;
// Enclose the display frustum in a head-parallel camera for the guest renderer.
// The compositor receives this rendered FOV and pose, not the canted display's.
inline bool ParallelEyeFov(const Quat& eye_orientation, const EyeFov& display,
EyeFov* rendered) {
Quat orientation;
if (!rendered || !QuatNormalize(eye_orientation, &orientation) ||
!(display.angle_left < display.angle_right) ||
!(display.angle_down < display.angle_up)) {
return false;
}
const float angles[] = {display.angle_left, display.angle_right,
display.angle_up, display.angle_down};
for (float angle : angles) {
if (!std::isfinite(angle) || std::abs(angle) >= kMaxEyeFovAngle)
return false;
}
if (orientation.x == 0.f && orientation.y == 0.f && orientation.z == 0.f) {
*rendered = display;
return true;
}
const float xs[] = {std::tan(display.angle_left),
std::tan(display.angle_right)};
const float ys[] = {std::tan(display.angle_down), std::tan(display.angle_up)};
float left = 0.f, right = 0.f, down = 0.f, up = 0.f;
bool first = true;
for (float x : xs) {
for (float y : ys) {
const Vec3 ray = QuatRotate(orientation, {x, y, -1.f});
if (!std::isfinite(ray.z) || ray.z >= -1e-6f) return false;
const float horizontal = ray.x / -ray.z;
const float vertical = ray.y / -ray.z;
if (!std::isfinite(horizontal) || !std::isfinite(vertical)) return false;
if (first) {
left = right = horizontal;
down = up = vertical;
first = false;
} else {
left = std::min(left, horizontal);
right = std::max(right, horizontal);
down = std::min(down, vertical);
up = std::max(up, vertical);
}
}
}
const EyeFov result{std::atan(left), std::atan(right), std::atan(up),
std::atan(down)};
const float bounds[] = {result.angle_left, result.angle_right,
result.angle_up, result.angle_down};
for (float angle : bounds) {
if (std::abs(angle) >= kMaxEyeFovAngle) return false;
}
*rendered = result;
return true;
}
// Metres-to-studs factor the exact-build native helper applies itself. Exposed
// only so tests can prove this layer does NOT pre-apply it.
inline constexpr float kMetresToStuds = 10.f / 3.f;
// Expected stud value for a metre offset, for numerical unit checks.
inline float MetresToStuds(float metres) { return metres * kMetresToStuds; }
} // namespace mocktail::vr::pose
#endif // MOCKTAIL_VR_POSE_MATH_H_
+101
View File
@@ -0,0 +1,101 @@
#ifndef MOCKTAIL_VR_XR_ACTIONS_H_
#define MOCKTAIL_VR_XR_ACTIONS_H_
#include <cstdint>
#include <string>
#include "mocktail/status.h"
#include "mocktail/vr/vr_pose_math.h"
#include "mocktail/vr/xr_controller.h"
namespace mocktail::vr {
// Session-owned OpenXR action layer: one action set, left/right subactions,
// grip+aim pose spaces, and interaction-profile bindings for Touch (primary),
// Quest 2, Index, Vive wands and the simple controller. All XR handles stay
// opaque here so non-OpenXR consumers (bridge, tests) need no XR headers.
//
// Frame contract: Sync() runs exactly once per frame from the backend frame
// cycle, immediately after xrWaitFrame returned the predicted display time and
// before the guest renders, so located hand poses share the head/eye frame.
// There is no second frame wait and no polling from guest getters.
class XrActions final {
public:
XrActions() = default;
~XrActions();
XrActions(const XrActions &) = delete;
XrActions &operator=(const XrActions &) = delete;
// Creates the action set/actions and suggests bindings. Instance-level:
// valid before any session exists. Failure is non-fatal for XR output; the
// backend logs and continues without controller input.
Status Create(void *xr_instance);
// Attaches to a session and creates the per-hand pose spaces. Must be
// called after xrCreateSession and before the first Sync; recreated on every
// session replacement (no stale handles survive).
Status Attach(void *xr_instance, void *xr_session, void *xr_local_space);
// Destroys spaces and detaches; the action set survives for reattach.
void Detach();
void Destroy();
bool attached() const { return attached_; }
// One per-frame sync at predicted_display_time. Locates grip and aim poses
// in the session's LOCAL space, reads analog/boolean states, refreshes
// connection state from the runtime's current interaction profile, and
// builds the coherent snapshot + delivery batch. Returns false when actions
// are unavailable (runtime refused sync); input is reset and a disconnect
// remains drainable even while the session is detached.
bool Sync(void *xr_session, std::uint64_t predicted_display_time,
std::uint64_t frame, std::uint64_t session_generation);
// Latest coherent snapshot (frame/session generation tagged).
const ControllerSnapshot &snapshot() const { return snapshot_; }
// Hand poses in metres/LOCAL space for the state-copy records.
const VrHandPose &hand(int index) const { return hands_[index & 1]; }
// Drains the next pending delivery batch built by Sync. Returns false when
// nothing is queued. Thread-safe: Sync runs on the render thread, the drain
// runs on the window thread. Backed by a bounded queue, so a slow consumer
// drops oldest frames and converges via resync rather than growing.
bool TakeDelivery(ControllerDelivery *out);
// Queues a vibration request for one hand. Safe from any thread; applied on
// the next Sync's XR call window. amplitude [0,1], duration in nanoseconds
// (0 = runtime default). A newer request for the same hand replaces the
// pending one. StopHaptics cancels.
void RequestHaptics(int hand, float amplitude, std::uint64_t duration_ns,
float frequency_hz);
void StopHaptics(int hand);
// Interaction-profile diagnostics (refreshed on profile-change events and
// every Sync that observes a change).
std::string ActiveProfile(int hand) const;
// Test seam: publish an externally sourced profile name (event dispatch in
// the backend) without XR calls.
void NoteProfileChanged();
// Called by the backend on focus/session loss, under controller ownership.
void ResetInput();
private:
struct Impl;
Impl *impl_ = nullptr;
bool created_ = false;
bool attached_ = false;
ControllerStateBuilder builder_;
ControllerSnapshot snapshot_;
VrHandPose hands_[2];
ControllerDeliveryQueue delivery_queue_;
std::uint64_t session_generation_ = 0;
// Last connection level handed to the consumer, to mark connection changes.
bool last_delivered_connected_[2] = {false, false};
};
} // namespace mocktail::vr
#endif // MOCKTAIL_VR_XR_ACTIONS_H_
+236
View File
@@ -0,0 +1,236 @@
#ifndef MOCKTAIL_VR_XR_CONTROLLER_H_
#define MOCKTAIL_VR_XR_CONTROLLER_H_
#include "mocktail/vr/xr_controller_abi.h"
#include <array>
#include <cstdint>
#include <deque>
#include <mutex>
#include <string>
#include <vector>
namespace mocktail::vr {
// One tracked controller hand, located at the same predicted display time as
// the head. Grip is the held-object pose; aim is the pointing pose. They are
// not interchangeable: the guest consumes grip for hands/tools and aim for UI
// rays. Position is metres, orientation a quaternion (x, y, z, w) in the
// OpenXR LOCAL reference space. The guest applies its own metres-to-studs
// conversion and hand pitch offset, so neither is pre-applied here.
struct VrHandPose {
float grip_position[3] = {0.f, 0.f, 0.f};
float grip_orientation[4] = {0.f, 0.f, 0.f, 1.f};
float aim_position[3] = {0.f, 0.f, 0.f};
float aim_orientation[4] = {0.f, 0.f, 0.f, 1.f};
// Pose validity, reported separately from button activity and connection so
// a controller can be connected with an untracked pose.
bool grip_valid = false;
bool aim_valid = false;
};
// Engine-facing controller capability, independent of which OpenXR
// interaction profile supplied it. The guest receives buttons and analog axes
// through its verified Android gamepad route, so this layer normalizes every
// supported profile onto that one stable contract.
enum class ControllerButton {
kTrigger, // analog click semantics; value reported separately
kSqueeze, // grip/squeeze
kThumbstick, // stick click
kFaceNorth, // right B / left Y
kFaceSouth, // right A / left X
kFaceEast, // right B equivalent on layouts that have it
kFaceWest, // left X equivalent
kMenu, // application-accessible menu only; never a system button
kCount,
};
// Capacitive contacts are distinct from presses. Some runtimes/profiles do
// not expose them; active=false means unavailable, not an inferred contact.
enum class ControllerTouch {
kTrigger,
kThumbstick,
kThumbrest,
kA,
kB,
kX,
kY,
kCount
};
enum class ControllerAxis {
kTrigger, // 0..1
kSqueeze, // 0..1
kThumbstickX, // -1..1
kThumbstickY, // -1..1
kCount,
};
// Analog trigger/squeeze thresholds used only where the runtime exposes a
// boolean click for an analog control. Hysteresis prevents edge chattering at
// the boundary; native analog values are always preserved alongside it.
struct AnalogClickThresholds {
float press = 0.75f;
float release = 0.6f;
};
// One coherent per-frame controller snapshot. Built once per frame by the
// backend after a single xrSyncActions, then read by native getters without
// re-polling. Button booleans are level state; edge detection happens once, at
// publish time, so repeated native getters never emit duplicate edges.
struct ControllerHandState {
bool connected = false;
bool pose_valid = false;
std::array<bool, static_cast<std::size_t>(ControllerTouch::kCount)> touches{};
std::array<bool, static_cast<std::size_t>(ControllerTouch::kCount)>
touch_active{};
std::array<bool, static_cast<std::size_t>(ControllerButton::kCount)>
buttons{};
std::array<float, static_cast<std::size_t>(ControllerAxis::kCount)> axes{};
// Interaction profile actually reported by the runtime for this hand, for
// diagnostics only (never used to gate input).
std::string profile;
};
struct ControllerSnapshot {
ControllerHandState hands[2]; // 0 = left, 1 = right
std::uint64_t frame = 0;
std::uint64_t session_generation = 0;
};
// Pure state machine: maps raw OpenXR action values onto the normalized
// contract and derives edges exactly once per published frame. No OpenXR or
// JNI types, so it is unit-testable with injected values.
class ControllerStateBuilder {
public:
explicit ControllerStateBuilder(AnalogClickThresholds thresholds = {});
// Records one raw action value for the given hand. Values not set by the
// runtime are neutral when explicitly marked inactive.
void SetTouch(int hand, ControllerTouch touch, bool value, bool active);
void SetButton(int hand, ControllerButton button, bool value, bool active);
void SetAxis(int hand, ControllerAxis axis, float value, bool active);
void SetConnected(int hand, bool connected);
void SetProfile(int hand, std::string profile);
void SetPoseValid(int hand, bool valid);
// Produces the snapshot for this frame and records which buttons changed so
// the caller can emit exactly one native edge per transition. frame must
// advance monotonically; publishing the same frame twice yields no edges.
struct Edge {
int hand = 0;
ControllerButton button = ControllerButton::kTrigger;
bool pressed = false;
};
ControllerSnapshot Publish(std::uint64_t frame, std::uint64_t generation,
std::vector<Edge> *edges);
// Neutralizes all axes and releases held buttons once (focus loss,
// disconnect, session stop). Produces release edges for anything held so no
// button can stay stuck down.
void ReleaseAll(std::uint64_t frame, std::uint64_t generation,
std::vector<Edge> *edges);
// Derives the click state for an analog control with hysteresis: latches on
// at `press`, off at `release`. Call after SetAxis for the same hand. Used
// for profiles that expose a trigger value without a click action; native
// analog values are always preserved regardless.
bool AnalogClick(int hand, ControllerAxis axis);
const ControllerSnapshot &snapshot() const { return snapshot_; }
private:
AnalogClickThresholds thresholds_;
ControllerSnapshot snapshot_;
// Last published level per hand/button, used for edge detection.
std::array<
std::array<bool, static_cast<std::size_t>(ControllerButton::kCount)>, 2>
published_buttons_{};
// Hysteresis latch per (hand, analog control): [hand*2 + axis].
std::array<bool, 4> analog_latch_{};
bool have_published_ = false;
};
// Maps the normalized contract onto the guest's verified Android gamepad
// identifiers, so XR controllers reuse the single authoritative input route
// and cannot double-fire alongside a physical SDL gamepad. hand selects the
// per-hand squeeze/thumbstick/trigger identifiers (0 = left, 1 = right).
// Returns -1 when the control has no keycode (analog click).
int32_t ControllerButtonToAndroidKey(int hand, ControllerButton button);
// Returns the Android MotionEvent axis id for one axis of the given hand
// (left/right trigger differ), or -1 when the control has no analog axis.
int32_t ControllerAxisToAndroidAxis(int hand, ControllerAxis axis);
// How many float components the guest expects for this axis (sticks 2, triggers
// 1).
int32_t ControllerAxisComponentCount(ControllerAxis axis);
// SDL3 gamepad ordinals. XR controller delivery reuses the existing router
// (RobloxInputRouter::HandleEvent) as the single authoritative event route:
// the router performs the verified SDL->Android key/axis conversion, level
// deduplication, focus gating and release-on-disconnect itself. Synthesized
// platform events must therefore carry SDL ordinals, not Android codes.
// hand selects the per-hand squeeze/thumbstick/trigger ordinals.
int32_t ControllerButtonToSdlOrdinal(int hand, ControllerButton button);
// SDL axis ordinal per hand for stick/trigger delivery; -1 when not an axis.
// Left hand: LEFTX/LEFTY/TRIGGERLEFT; right: RIGHTX/RIGHTY/TRIGGERRIGHT.
int32_t ControllerAxisToSdlOrdinal(int hand, ControllerAxis axis);
// One drained delivery batch from the XR backend to the input route. Built
// once per frame after xrSyncActions; edges appear exactly once. `resync`
// requests full level re-delivery (recovery after queue overflow or an
// unfocused drop) — the router deduplicates, so re-delivery is idempotent and
// guarantees no button sticks down.
struct ControllerDelivery {
std::uint64_t session_generation = 0;
bool button_levels[2][static_cast<std::size_t>(ControllerButton::kCount)] =
{};
bool connected[2] = {false, false};
bool connection_changed[2] = {false, false};
struct ButtonEdge {
int hand = 0;
ControllerButton button = ControllerButton::kTrigger;
bool pressed = false;
};
std::vector<ButtonEdge> edges;
// SDL ordinal order: LEFTX, LEFTY, RIGHTX, RIGHTY, TRIGGERLEFT, TRIGGERRIGHT.
float axes[6] = {0.f, 0.f, 0.f, 0.f, 0.f, 0.f};
bool axes_valid[2] = {false, false};
bool resync = false;
std::uint64_t frame = 0;
// Bounded growth guard: the backend caps the edge queue and sets resync
// instead of growing without limit.
static constexpr std::size_t kMaxEdges = 64;
};
bool EncodeNativeControllerChannels(const ControllerSnapshot &snapshot,
float (&channels)[28]);
void EncodeControllerDelivery(const ControllerDelivery &delivery,
MocktailVrControllerDelivery *out);
// Thread-safe bounded queue between the XR render-thread producer (one push
// per frame after xrSyncActions) and the window-thread consumer that drains
// into the verified input route. Overflow drops the oldest frames and arms a
// resync on the next delivered frame, so the consumer always converges to the
// current levels and a full queue can never leave a button stuck down.
class ControllerDeliveryQueue {
public:
static constexpr std::size_t kCapacity = 8;
void Push(ControllerDelivery delivery);
bool Pop(ControllerDelivery *out);
std::size_t size() const;
bool overflowed() const { return overflowed_; }
std::uint64_t dropped_frames() const { return dropped_frames_; }
void Clear();
private:
mutable std::mutex mutex_;
std::deque<ControllerDelivery> queue_;
bool overflowed_ = false;
std::uint64_t dropped_frames_ = 0;
};
} // namespace mocktail::vr
#endif // MOCKTAIL_VR_XR_CONTROLLER_H_
+76
View File
@@ -0,0 +1,76 @@
#ifndef MOCKTAIL_VR_XR_CONTROLLER_ABI_H_
#define MOCKTAIL_VR_XR_CONTROLLER_ABI_H_
#include <stddef.h>
#include <stdint.h>
/* Plain-C mirror of mocktail::vr::ControllerDelivery for crossing the
* dlsym module boundary (XR backend -> input runtime) without exporting C++
* types or requiring the consumer to link OpenXR. Field order and types are
* the ABI; both sides include this header. Keep it POD: no constructors, no
* std, fixed arrays only. */
#ifdef __cplusplus
extern "C" {
#endif
#define MOCKTAIL_VR_CONTROLLER_MAX_EDGES 64
#define MOCKTAIL_VR_CONTROLLER_BUTTON_COUNT 8
#define MOCKTAIL_VR_CONTROLLER_AXIS_COUNT 6
/* Button indices: mirror mocktail::vr::ControllerButton enum order. */
enum {
MOCKTAIL_VR_BUTTON_TRIGGER = 0,
MOCKTAIL_VR_BUTTON_SQUEEZE = 1,
MOCKTAIL_VR_BUTTON_THUMBSTICK = 2,
MOCKTAIL_VR_BUTTON_FACE_NORTH = 3,
MOCKTAIL_VR_BUTTON_FACE_SOUTH = 4,
MOCKTAIL_VR_BUTTON_FACE_EAST = 5,
MOCKTAIL_VR_BUTTON_FACE_WEST = 6,
MOCKTAIL_VR_BUTTON_MENU = 7
};
typedef struct MocktailVrControllerEdge {
int32_t hand; /* 0 = left, 1 = right */
int32_t button; /* MOCKTAIL_VR_BUTTON_* */
int32_t pressed; /* 0/1 */
} MocktailVrControllerEdge;
typedef struct MocktailVrControllerDelivery {
uint64_t frame;
uint64_t session_generation;
int32_t connected[2];
int32_t connection_changed[2];
/* Current button levels per hand (MOCKTAIL_VR_BUTTON_* order). Used for
* resync after queue overflow: the consumer re-delivers every level and the
* router's deduplication makes it idempotent. */
int32_t button_levels[2][MOCKTAIL_VR_CONTROLLER_BUTTON_COUNT];
/* Analog levels in SDL gamepad axis order:
* 0=LEFTX 1=LEFTY 2=RIGHTX 3=RIGHTY 4=TRIGGERLEFT 5=TRIGGERRIGHT. */
float axes[MOCKTAIL_VR_CONTROLLER_AXIS_COUNT];
int32_t axes_valid[2];
int32_t resync;
int32_t edge_count;
MocktailVrControllerEdge edges[MOCKTAIL_VR_CONTROLLER_MAX_EDGES];
} MocktailVrControllerDelivery;
/* Pops the next delivery batch produced by the XR backend's once-per-frame
* action sync. Returns 1 and fills *out when a batch was queued, 0 when the
* queue is empty or no XR controller layer is active. Inert (returns 0) in
* non-VR builds and while no backend is armed. */
typedef int32_t (*MocktailVrControllerPopFn)(MocktailVrControllerDelivery *out);
/* Queues a vibration request for one hand (0=left, 1=right). amplitude is
* clamped to [0,1]; duration_ns 0 means the runtime default. Safe from any
* thread; applied on the next XR sync. No-op when no controller layer is
* active. */
typedef void (*MocktailVrControllerHapticsFn)(int32_t hand, float amplitude,
uint64_t duration_ns,
float frequency_hz);
typedef void (*MocktailVrControllerStopHapticsFn)(int32_t hand);
#ifdef __cplusplus
}
#endif
#endif /* MOCKTAIL_VR_XR_CONTROLLER_ABI_H_ */
+27
View File
@@ -0,0 +1,27 @@
#ifndef MOCKTAIL_RUNTIME_ASSET_OVERLAY_NAMES_H_
#define MOCKTAIL_RUNTIME_ASSET_OVERLAY_NAMES_H_
#include <cstddef>
#include <cstdint>
#include <filesystem>
#include <string>
#include <vector>
namespace mocktail {
namespace runtime {
// The rbxasset:// name of every regular file under <overlay_dir>/content, such
// as rbxasset://textures/MouseLockedCursor.png. Sorted.
std::vector<std::string> OverlayRbxAssetNames(
const std::filesystem::path& overlay_dir);
// Roblox opens no local file for an rbxasset:// name it maps to a download.
// Writes '!' over the first byte of each whole NUL-terminated copy of a name
// in `data`, which unmaps it. Returns the number of copies changed.
std::size_t DisableAssetNameMappings(std::uint8_t* data, std::size_t size,
const std::vector<std::string>& names);
}
}
#endif
+4
View File
@@ -1,6 +1,7 @@
#ifndef MOCKTAIL_RUNTIME_COMMAND_LINE_H_
#define MOCKTAIL_RUNTIME_COMMAND_LINE_H_
#include <optional>
#include <string>
#include <vector>
@@ -21,6 +22,7 @@ enum class WindowMode {
struct CommandLineOptions {
CommandMode mode = CommandMode::kRun;
WindowMode window_mode = WindowMode::kUnspecified;
std::optional<bool> vr_enabled;
std::string program_name = "mocktail";
std::string roblox_library_path;
std::string graphics_backend;
@@ -33,6 +35,8 @@ struct CommandLineOptions {
// Requests one explicit launch of the provider latest without promoting it
// into the managed payload state.
bool force_run_latest = false;
// Chrome trace file the Vulkan adapter writes; empty when not profiling.
std::string profile_trace_path;
};
struct CommandLineParseResult {
+5 -1
View File
@@ -13,11 +13,14 @@ namespace mocktail {
namespace runtime {
struct DiscordRpcActivity {
std::string name;
std::string details;
std::string state;
std::optional<int64_t> start_timestamp;
std::string large_image;
std::string large_text;
std::string small_image;
std::string small_text;
std::string button_label;
std::string button_url;
};
@@ -27,7 +30,8 @@ std::string BuildDiscordJoinUrl(const RobloxExperienceLaunchRequest& request);
DiscordRpcActivity BuildDiscordRpcActivity(
const DiscordRpcConfig& config, RobloxExperiencePresencePhase phase,
const RobloxExperienceLaunchRequest* request, std::string place_name,
int64_t session_started_at, std::string place_icon_url = {});
int64_t session_started_at, std::string place_icon_url = {},
std::string creator_name = {});
// Maintains a direct, unauthenticated Discord Desktop RPC connection. The
// worker owns all network and IPC work; lifecycle notifications only replace
+65
View File
@@ -0,0 +1,65 @@
#ifndef MOCKTAIL_RUNTIME_ENGINE_PUMP_REST_H_
#define MOCKTAIL_RUNTIME_ENGINE_PUMP_REST_H_
#include <cstdint>
namespace mocktail::runtime {
// The engine posts its per-frame main-thread step at a time of its own
// choosing, with no signal the host can block on, so the host main thread
// polls nativeCallMessagesFromMainThread. An empty poll costs a few
// microseconds; polled back to back it burns a core.
//
// Between polls the thread rests for kEnginePumpRestNs. A post is then picked
// up at most kEnginePumpRestNs + timer slack + wake latency late, far under
// the engine's sub-millisecond slack.
//
// How it rests decides the clock. Under a power-saving governor the CPU clocks
// a bursty core down unless some core stays awake, and the engine's render
// thread then runs slower and drops frames. So:
// kSleep nanosleep. The core idles. Correct only when the governor holds
// the clock up on its own (performance, which GameMode sets).
// kTpause TPAUSE in C0.2. The core stays awake at reduced power, the clock
// holds, and the OS books the time as busy. Needs WAITPKG.
// kSpin No rest. The core stays awake at full power.
inline constexpr uint64_t kEnginePumpRestNs = 100'000;
// Timer slack the calling thread is set to; the kernel default of 50 us would
// otherwise stretch every sleep.
inline constexpr uint64_t kEnginePumpTimerSlackNs = 10'000;
// Shallow C-state exit plus scheduler latency on the host, for the bound.
inline constexpr uint64_t kShallowWakeNs = 50'000;
enum class EnginePumpRestMode { kSleep, kTpause, kSpin };
const char* EnginePumpRestModeName(EnginePumpRestMode mode);
struct EnginePumpRestInputs {
bool governor_performance = false;
bool cpu_has_waitpkg = false;
// MOCKTAIL_ENGINE_PUMP_REST: "sleep", "tpause" or "spin". Null, empty or
// unknown defers to the policy; "tpause" without WAITPKG does too.
const char* override = nullptr;
};
EnginePumpRestMode ChooseEnginePumpRestMode(const EnginePumpRestInputs& inputs);
// True when the file holds "performance". `path` is a cpufreq
// scaling_governor file; cpu0's stands for the machine.
bool CpuGovernorIsPerformance(const char* path);
bool CpuHasWaitPkg();
// The mode chosen from the host on first use.
EnginePumpRestMode ActiveEnginePumpRestMode();
// Rests the calling thread in the given mode and returns the rest length, 0
// for kSpin. The first call lowers the thread's timer slack to
// kEnginePumpTimerSlackNs.
uint64_t RestAfterEnginePump(EnginePumpRestMode mode);
inline uint64_t RestAfterEnginePump() {
return RestAfterEnginePump(ActiveEnginePumpRestMode());
}
} // namespace mocktail::runtime
#endif // MOCKTAIL_RUNTIME_ENGINE_PUMP_REST_H_
+34
View File
@@ -1,6 +1,8 @@
#ifndef MOCKTAIL_RUNTIME_GAME_MODE_H_
#define MOCKTAIL_RUNTIME_GAME_MODE_H_
#include <sched.h>
#include <string>
#include <string_view>
@@ -22,6 +24,7 @@ enum class GameModeSessionState {
kDisabled,
kUnavailable,
kRequestFailed,
kDeclinedCorePinning,
kAlreadyActive,
kActive,
kStopped,
@@ -43,9 +46,37 @@ struct GameModeClientApi {
ErrorFn error_string = nullptr;
};
// The CPU affinity calls, injectable so tests exercise the restore without
// moving the threads they run on.
struct CpuAffinityApi {
// Reads the calling thread's permitted CPUs. GameMode pins every thread
// alike, so one thread's mask is enough to detect the pin.
using GetFn = int (*)(cpu_set_t* mask);
// Applies `mask` to every thread of this process, not only the caller.
// Returns 0 when each live thread took it.
using SetFn = int (*)(const cpu_set_t* mask);
GetFn get = nullptr;
SetFn set = nullptr;
};
CpuAffinityApi HostCpuAffinityApi();
// True when `after` permits a strict subset of the CPUs `before` permits.
// An equal, wider or merely relocated set is not a narrowing.
bool CpuAffinityNarrowed(const cpu_set_t& before, const cpu_set_t& after);
// Owns at most one ref-counted GameMode request. Missing host support and
// daemon rejection remain nonfatal because GameMode is an optimization, not a
// correctness dependency.
//
// GameMode pins the calling process to the host's fastest cores when it sees a
// wide core-speed spread. Roblox spreads its own work over a worker pool, so
// that pin concentrates the load onto a couple of cores instead of helping,
// and the daemon re-applies it every few seconds, so correcting the mask in
// place does not hold. Under `auto`, a request that narrows the affinity is
// released again: ending the request is what releases the pin. `on` is an
// explicit choice and keeps the pinned session.
class GameModeSession final {
public:
GameModeSession() = default;
@@ -59,6 +90,8 @@ class GameModeSession final {
static GameModeSession Start(GameModePolicy policy);
static GameModeSession StartWithClientForTesting(
GameModePolicy policy, GameModeClientApi api);
static GameModeSession StartWithClientForTesting(
GameModePolicy policy, GameModeClientApi api, CpuAffinityApi affinity);
GameModeSessionState state() const { return state_; }
const std::string& detail() const { return detail_; }
@@ -73,6 +106,7 @@ class GameModeSession final {
private:
static GameModeSession StartBound(GameModePolicy policy,
GameModeClientApi api,
CpuAffinityApi affinity,
void* library_handle);
void CloseLibrary();
+20
View File
@@ -18,6 +18,26 @@ std::string SelectVulkanIcdManifest(
const std::vector<std::filesystem::path>& directories,
std::string_view vendor);
// One GPU as the kernel reports it.
struct HostGpu {
unsigned int vendor = 0; // PCI vendor ID.
std::string kernel_driver; // Bound kernel driver; empty when unknown.
};
struct VulkanIcdSelection {
std::string manifest; // Empty when no GPU has a usable driver.
std::vector<std::string> skipped; // Why each passed-over GPU was skipped.
};
// ICD manifest for the first GPU whose bound kernel driver has an installed
// Vulkan driver. NVIDIA and AMD cards are tried before Intel when
// `prefer_discrete` is set, and after it otherwise. A GPU with an unknown
// kernel driver is matched by vendor alone.
VulkanIcdSelection SelectHostVulkanIcd(
const std::vector<HostGpu>& gpus,
const std::vector<std::filesystem::path>& directories,
bool prefer_discrete);
// Publishes the resolved graphics backend before the managed payload updater
// starts. OpenGL is a strict system EGL/GLES path; it never silently retries
// through ANGLE/Vulkan or accepts a window without a real graphics context.
+68
View File
@@ -0,0 +1,68 @@
#ifndef MOCKTAIL_RUNTIME_LUAU_CODEGEN_SWITCH_H_
#define MOCKTAIL_RUNTIME_LUAU_CODEGEN_SWITCH_H_
#include <cstddef>
#include <cstdint>
#include <functional>
#include <mutex>
#include <string_view>
#include <vector>
#include "runtime/roblox_experience_presence.h"
namespace mocktail {
namespace runtime {
// Nightcap defaults this on and keeps it on only outside games; a user value
// set in fflags.json or the overrides environment disables the switching.
inline constexpr std::string_view kLuauCodegenAllClientSetting =
"FFlagDebugLuauCodegenAll";
inline constexpr std::string_view kLuauCodegenAllFastFlagName =
"DebugLuauCodegenAll";
struct LoadedImageSegment {
std::uintptr_t address = 0;
std::size_t size = 0;
bool writable = false;
};
std::vector<LoadedImageSegment> LoadedImageSegments(std::uintptr_t image_base);
// Record layout: {u8 value; pad; const char* name; size_t length}, in a
// writable segment filled by libroblox's static constructors. Returns null
// unless exactly one record names a NUL-terminated copy of `name`.
std::uint8_t* FindFastFlagValue(
const std::vector<LoadedImageSegment>& segments, std::string_view name);
bool ClientSettingsSetLuauCodegenAll(std::string_view overrides_json);
bool LuauCodegenAllForPhase(RobloxExperiencePresencePhase phase);
// Locates the flag on the first notification; a byte that is not 0 or 1 is
// left alone.
class LuauCodegenMenuSwitch {
public:
using LocateFlagFn = std::function<std::uint8_t*()>;
LuauCodegenMenuSwitch(LocateFlagFn locate_flag,
RobloxExperiencePresenceObserver next);
RobloxExperiencePresenceObserver observer();
void OnPhase(RobloxExperiencePresencePhase phase,
const RobloxExperienceLaunchRequest* request);
private:
static void Notify(void* context, RobloxExperiencePresencePhase phase,
const RobloxExperienceLaunchRequest* request);
std::mutex mutex_;
LocateFlagFn locate_flag_;
bool located_ = false;
std::uint8_t* flag_ = nullptr;
const RobloxExperiencePresenceObserver next_;
};
} // namespace runtime
} // namespace mocktail
#endif // MOCKTAIL_RUNTIME_LUAU_CODEGEN_SWITCH_H_
+5
View File
@@ -64,6 +64,11 @@ bool MergeRuntimeClientSettingsOverrides(
std::string_view base_json, std::string* merged_json, std::string* error,
std::vector<std::string>* kept_overrides = nullptr);
// The resolved VR mode owns this flag in both client-settings channels.
bool MergeVrClientSettingsOverrides(bool enabled, std::string_view base_json,
std::string* merged_json,
std::string* error);
// Keeps the legacy test bypass disabled. The host PermissionsProtocol now
// answers microphone authorization from capture configuration. No permission
// query opens a recording device.
+2 -2
View File
@@ -47,8 +47,8 @@ struct RobloxThemeCacheResult {
};
// Reads the current account's Roblox theme without changing appStorage. A
// missing value leaves dark_theme empty so the caller can use Roblox's own
// startup default.
// missing or unrecognized value falls back to AuthenticatedTheme, then the
// caller's startup default.
RobloxThemeCacheResult ReadRobloxThemeCache(
const std::filesystem::path& app_storage_file,
std::int64_t authenticated_user_id);
+94
View File
@@ -0,0 +1,94 @@
#ifndef MOCKTAIL_RUNTIME_ROBLOX_ACCOUNT_BRIDGE_H_
#define MOCKTAIL_RUNTIME_ROBLOX_ACCOUNT_BRIDGE_H_
#include <jni.h>
#include <memory>
#include <mutex>
#include <string>
#include "mocktail/status.h"
#include "runtime/roblox_game_session_native_adapter.h"
namespace mocktail {
namespace runtime {
// Exact JNI entrypoints behind the APK AccountProtocol device-integrity
// handlers: JNIAccountProtocol name getters and MessageBus raw handlers.
struct RobloxAccountProtocolSymbols {
using GetStringFn = jstring (*)(JNIEnv*, jclass);
GetStringFn get_protocol_name = nullptr;
GetStringFn get_device_integrity_available_method_name = nullptr;
GetStringFn get_integrity_token_method_name = nullptr;
GetStringFn get_support_key = nullptr;
GetStringFn get_token_key = nullptr;
GetStringFn get_result_key = nullptr;
void (*set_request_handler_raw)(JNIEnv*, jobject, jstring, jstring,
jobject) = nullptr;
void (*clear_request_handler)(JNIEnv*, jobject, jstring, jstring) = nullptr;
bool complete() const {
return get_protocol_name && get_device_integrity_available_method_name &&
get_integrity_token_method_name && get_support_key &&
get_token_key && get_result_key && set_request_handler_raw &&
clear_request_handler;
}
};
struct RobloxAccountProtocolObjects {
jobject message_bus = nullptr;
void* context = nullptr;
jobject (*create_request_handler)(void*, std::shared_ptr<void>,
std::string (*)(void*, JNIEnv*,
jstring)) = nullptr;
void (*clear_request_handler)(void*, jobject) = nullptr;
bool complete() const {
return message_bus && context && create_request_handler &&
clear_request_handler;
}
};
// Answers AccountProtocol device-integrity requests for a host that cannot
// produce Play Integrity tokens. No integrity token is ever produced.
//
// DeviceIntegrityAvailable reports no support. The APK handler always reports
// support, so this answer differs from Android: it tells Roblox's challenge
// flow up front that no token can come, instead of letting it request one.
//
// GetIntegrityToken returns at once with an empty token and
// TOKEN_PROVIDER_UNINITIALIZED, the APK's answer when its Play token provider
// never initialized. The APK answers INVALID_NONCE for a request without a
// requestHash; this bridge never inspects the request, so it cannot.
// LuaApp bounds each token request with its
// DeviceIntegrityNativeTimeoutMilliseconds flag; an immediate answer keeps a
// login challenge from sitting out that bound.
class RobloxAccountBridge final {
public:
RobloxAccountBridge(JniEnvironmentProvider environment,
RobloxAccountProtocolSymbols symbols,
RobloxAccountProtocolObjects objects);
~RobloxAccountBridge();
RobloxAccountBridge(const RobloxAccountBridge&) = delete;
RobloxAccountBridge& operator=(const RobloxAccountBridge&) = delete;
Status Initialize();
Status Shutdown();
private:
struct State;
Status ShutdownLocked();
const JniEnvironmentProvider environment_;
const RobloxAccountProtocolSymbols symbols_;
const RobloxAccountProtocolObjects objects_;
// Serializes lifecycle only. Native dispatch never holds this mutex.
std::mutex lifecycle_mutex_;
std::shared_ptr<State> state_;
};
} // namespace runtime
} // namespace mocktail
#endif // MOCKTAIL_RUNTIME_ROBLOX_ACCOUNT_BRIDGE_H_
@@ -0,0 +1,26 @@
#ifndef MOCKTAIL_RUNTIME_ROBLOX_CALL_PROTOCOL_BRIDGE_H_
#define MOCKTAIL_RUNTIME_ROBLOX_CALL_PROTOCOL_BRIDGE_H_
#include "runtime/roblox_message_bus_request_bridge.h"
namespace mocktail {
namespace runtime {
// In-experience CoreScripts query the app's cross-experience call state before
// initializing voice. Mocktail does not start or accept cross-experience calls,
// so that state is Idle. This query neither joins voice nor opens a microphone.
class RobloxCallProtocolBridge final {
public:
RobloxCallProtocolBridge(JniEnvironmentProvider environment,
RobloxMessageBusSymbols symbols,
RobloxMessageBusObjects objects);
Status Initialize();
Status Shutdown();
private:
RobloxMessageBusRequestBridge bridge_;
};
} // namespace runtime
} // namespace mocktail
#endif
@@ -38,6 +38,16 @@ bool MergeDesktopAppPolicyClientSettingsOverride(std::string_view policy_json,
std::string* merged_json,
std::string* error);
// Apply after desktop policy composition. Roblox 2998's Android payload is not
// a VRAppBuild, so its LuaApp enables RunService's idle render throttle
// even with DebugDeviceVR active. Preserve the effective app-policy (the later
// fast-flags channel takes precedence) in both channels, changing only
// ThrottleFramerate. This does not write the setting to appStorage.
bool MergeVrAppPolicyClientSettingsOverrides(
bool enabled, std::string_view client_settings_json,
std::string_view fast_flags_json, std::string* merged_client_settings,
std::string* merged_fast_flags, std::string* error);
} // namespace runtime
} // namespace mocktail
@@ -3,6 +3,7 @@
#include <jni.h>
#include <chrono>
#include <cstdint>
#include <deque>
#include <memory>
@@ -14,9 +15,12 @@
#include "mocktail/status.h"
#include "runtime/owned_pthread.h"
#include "runtime/roblox_browser_service_bridge.h"
#include "runtime/roblox_call_protocol_bridge.h"
#include "runtime/roblox_experience_launch_bridge.h"
#include "runtime/roblox_experience_presence.h"
#include "runtime/roblox_fresh_game_launch_controller.h"
#include "runtime/roblox_haptic_protocol_bridge.h"
#include "runtime/roblox_account_bridge.h"
#include "runtime/roblox_permissions_bridge.h"
#include "runtime/roblox_web_view_bridge.h"
#include "runtime/webview_roblox_cookie.h"
@@ -95,7 +99,9 @@ struct RobloxExperienceSurfaceProvider {
void* context = nullptr;
GameSurface (*snapshot)(void* context) = nullptr;
bool valid() const { return snapshot != nullptr; }
bool valid() const {
return snapshot != nullptr;
}
};
// Production composition for the APK platform protocols and ExperienceProtocol
@@ -120,7 +126,8 @@ class RobloxExperienceComposition final {
RobloxExperienceSurfaceProvider surface_provider = {},
RobloxExperiencePresenceObserver presence_observer = {},
bool clear_persisted_web_view_cookie = false,
bool microphone_enabled = false);
bool microphone_enabled = false,
RobloxAccountProtocolSymbols account_symbols = {});
~RobloxExperienceComposition();
RobloxExperienceComposition(const RobloxExperienceComposition&) = delete;
@@ -162,6 +169,7 @@ class RobloxExperienceComposition final {
bool visible = true;
bool back_navigation_disabled = false;
bool show_domain_as_title = false;
bool close_when_challenge_solved = false;
};
static Status DispatchLaunch(void* context,
@@ -201,8 +209,11 @@ class RobloxExperienceComposition final {
const WebSurfacePresentation& presentation);
// WebViewProtocol and BrowserService are two logical transports over the
// APK's single physical WebView container. Either protocol may close or
// mutate the surface that the other one opened.
Status CloseWebSurface();
// mutate the surface that the other one opened. A nonzero
// expected_logical_generation closes only that surface.
Status CloseWebSurface(uint64_t expected_logical_generation = 0);
// Closes a solved challenge that Roblox left open past its grace period.
Status CloseUnclaimedSolvedChallenge();
void HandleWebSurfaceExit(uint64_t process_generation);
Status Dispatch(const RobloxExperienceLaunchRequest& request);
Status PromoteAuthenticatedSession();
@@ -229,6 +240,7 @@ class RobloxExperienceComposition final {
const RobloxWebViewMessageBusSymbols web_view_symbols_;
const RobloxBrowserServiceSymbols browser_service_symbols_;
const RobloxPermissionsMessageBusSymbols permissions_symbols_;
const RobloxAccountProtocolSymbols account_symbols_;
const bool microphone_enabled_;
const RobloxGameSessionSymbols game_symbols_;
const RobloxExperienceJniFactory jni_factory_;
@@ -248,6 +260,10 @@ class RobloxExperienceComposition final {
std::unique_ptr<RobloxWebViewBridge> web_view_bridge_;
std::unique_ptr<RobloxBrowserServiceBridge> browser_service_bridge_;
std::unique_ptr<RobloxPermissionsBridge> permissions_bridge_;
std::unique_ptr<RobloxCallProtocolBridge> call_protocol_bridge_;
std::unique_ptr<RobloxHapticProtocolBridge> haptic_protocol_bridge_;
// Optional: a payload without the AccountProtocol entrypoints still starts.
std::unique_ptr<RobloxAccountBridge> account_bridge_;
std::shared_ptr<WebViewHelperProcess> web_surface_process_;
std::shared_ptr<WebSurfaceExitTarget> web_surface_exit_target_;
std::shared_ptr<LifecycleTarget> lifecycle_target_;
@@ -257,6 +273,13 @@ class RobloxExperienceComposition final {
uint64_t web_surface_logical_generation_ = 0;
uint64_t next_web_surface_process_generation_ = 1;
uint64_t next_web_surface_logical_generation_ = 1;
// The current surface closes as soon as its solved challenge is forwarded.
bool web_surface_close_when_challenge_solved_ = false;
// Set when a solved challenge is forwarded; applies only to that logical
// surface generation.
std::optional<std::chrono::steady_clock::time_point>
solved_challenge_close_deadline_;
uint64_t solved_challenge_logical_generation_ = 0;
SecureWebViewRobloxCookie web_view_cookie_;
std::string web_view_cookie_initialization_error_;
bool web_view_cookie_synchronized_ = false;
@@ -0,0 +1,47 @@
#ifndef MOCKTAIL_RUNTIME_ROBLOX_HAPTIC_PROTOCOL_BRIDGE_H_
#define MOCKTAIL_RUNTIME_ROBLOX_HAPTIC_PROTOCOL_BRIDGE_H_
#include <cstdint>
#include <functional>
#include <memory>
#include <string>
#include "runtime/roblox_message_bus_request_bridge.h"
namespace mocktail {
namespace runtime {
using HapticSinkFn = std::function<void(int hand, float amplitude,
uint64_t duration_ns, float frequency_hz)>;
using HapticStopFn = std::function<void(int hand)>;
using HapticStateSyncFn = std::function<void(JNIEnv* env)>;
class HapticWaveformPlayer;
class RobloxHapticProtocolBridge final {
public:
RobloxHapticProtocolBridge(
JniEnvironmentProvider environment,
RobloxMessageBusSymbols symbols,
RobloxMessageBusObjects objects,
HapticSinkFn haptic_sink = nullptr,
HapticStopFn haptic_stop = nullptr,
HapticStateSyncFn haptic_state_sync = nullptr);
~RobloxHapticProtocolBridge();
Status Initialize();
Status Shutdown();
static std::string GetMotorsJson();
// True during a VR session; main() sets MOCKTAIL_VR_ENABLED to 0 when VR
// cannot start, so the bridge stays off for desktop play.
static bool EnabledForThisSession();
private:
std::shared_ptr<HapticWaveformPlayer> player_;
std::unique_ptr<RobloxMessageBusRequestBridge> bridge_;
};
} // namespace runtime
} // namespace mocktail
#endif
+13 -3
View File
@@ -40,9 +40,10 @@ struct AndroidKeyMapping {
// through with an ABI-incompatible numeric value.
AndroidKeyMapping MapSdlKeyToAndroid(uint32_t sdl_scancode, uint32_t sdl_key = 0);
// Mirrors the APK's MotionEvent.getActionButton() - 1 conversion. SDL button
// numbers are not numerically compatible with Android button bit values.
int32_t MapSdlMouseButtonToAndroid(uint8_t sdl_button);
// Translates SDL button numbers to the nativePassMouseButton argument.
// The first three are Roblox's zero-based MouseButton1/2/3 indices, not Android
// MotionEvent button bits.
int32_t MapSdlMouseButtonToRoblox(uint8_t sdl_button);
int32_t MapSdlGamepadButtonToAndroid(uint8_t sdl_button);
int32_t RobloxGamepadType(platform::GamepadFamily family);
@@ -223,6 +224,10 @@ class RobloxInputRouter final {
Status DisconnectGamepadLocked(ActiveGamepad& gamepad);
Status DisconnectGamepadsLocked();
Status ReleasePressedInputsLocked();
// Keeps mouse_x_/mouse_y_ where they are for an event that carries no
// usable position, clamped to the current viewport. Before any absolute
// position has been seen, the held position is the viewport center.
void HoldPointerLocked(const platform::SurfaceCoordinateTransform& transform);
int32_t AllocatePointerIdLocked() const;
std::vector<ActiveTouch>::iterator FindTouchIteratorLocked(int64_t touch_id,
int64_t finger_id);
@@ -238,8 +243,13 @@ class RobloxInputRouter final {
std::vector<ActiveTouch> active_touches_;
std::vector<ActiveKey> active_keys_;
std::vector<ActiveGamepad> gamepads_;
// Last known pointer position in guest units. Relative-mode motion, and
// wheel or button events with zero coordinates, do not move it: the pointer
// stays where it was captured while the deltas pass through.
float mouse_x_ = 0.0f;
float mouse_y_ = 0.0f;
// True once an absolute position has been received since Activate.
bool pointer_anchored_ = false;
bool raw_mouse_ = false;
};
+7
View File
@@ -19,6 +19,13 @@ inline constexpr std::size_t kMaximumRobloxLaunchUriBytes = 64 * 1024;
Status ParseRobloxLaunchUri(std::string_view uri,
RobloxExperienceLaunchRequest* request);
// Converts a WebView launchGame command
// ({"functionName":"launchGame","params":{"request":{...}}}) into the same
// contract. Returns kUnsupported for other commands and kInvalidArgument for
// a malformed launchGame request.
Status ParseRobloxWebViewLaunchGame(std::string_view command,
RobloxExperienceLaunchRequest* request);
} // namespace runtime
} // namespace mocktail
@@ -0,0 +1,87 @@
#ifndef MOCKTAIL_RUNTIME_ROBLOX_MESSAGE_BUS_REQUEST_BRIDGE_H_
#define MOCKTAIL_RUNTIME_ROBLOX_MESSAGE_BUS_REQUEST_BRIDGE_H_
#include <jni.h>
#include <functional>
#include <memory>
#include <mutex>
#include <string>
#include <vector>
#include "mocktail/status.h"
#include "runtime/roblox_game_session_native_adapter.h"
namespace mocktail {
namespace runtime {
struct RobloxMessageBusSymbols {
jobject (*subscribe_request)(JNIEnv*, jobject, jstring, jstring, jobject,
jboolean) = nullptr;
void (*delete_connection)(JNIEnv*, jobject, jlong) = nullptr;
void (*publish_response)(JNIEnv*, jobject, jstring, jstring, jstring, jint,
jstring) = nullptr;
void (*set_async_handler)(JNIEnv*, jobject, jstring, jstring,
jobject) = nullptr;
void (*clear_handler)(JNIEnv*, jobject, jstring, jstring) = nullptr;
void (*call_response_handler)(JNIEnv*, jobject, jstring, jstring) = nullptr;
bool complete() const {
return subscribe_request && delete_connection && publish_response &&
set_async_handler && clear_handler && call_response_handler;
}
};
struct RobloxMessageBusObjects {
jobject message_bus = nullptr;
void* context = nullptr;
jobject (*create_raw_callback)(void*, std::shared_ptr<void>,
void (*)(void*, JNIEnv*, jstring)) = nullptr;
void (*clear_raw_callback)(void*, jobject) = nullptr;
jobject (*create_async_handler)(void*, std::shared_ptr<void>,
void (*)(void*, JNIEnv*, jstring,
jstring)) = nullptr;
void (*clear_async_handler)(void*, jobject) = nullptr;
bool complete() const {
return message_bus && context && create_raw_callback &&
clear_raw_callback && create_async_handler && clear_async_handler;
}
};
struct RobloxMessageBusReply {
std::string json;
jint code = 0;
const char* outcome = "ok";
};
// Owns both legacy subscriptions and correlated async request handlers.
// A policy receives only a request's method index and JNI-local payload.
class RobloxMessageBusRequestBridge final {
public:
using Handler =
std::function<RobloxMessageBusReply(JNIEnv*, jstring, std::size_t)>;
RobloxMessageBusRequestBridge(JniEnvironmentProvider environment,
RobloxMessageBusSymbols symbols,
RobloxMessageBusObjects objects,
std::string protocol,
std::vector<std::string> methods,
Handler handler);
~RobloxMessageBusRequestBridge();
Status Initialize();
Status Shutdown();
private:
struct State;
Status ShutdownLocked();
const JniEnvironmentProvider environment_;
const RobloxMessageBusSymbols symbols_;
const RobloxMessageBusObjects objects_;
const std::string protocol_name_;
const std::vector<std::string> methods_;
const Handler handler_;
std::mutex lifecycle_mutex_;
std::shared_ptr<State> state_;
};
} // namespace runtime
} // namespace mocktail
#endif
+5 -65
View File
@@ -1,58 +1,12 @@
#ifndef MOCKTAIL_RUNTIME_ROBLOX_PERMISSIONS_BRIDGE_H_
#define MOCKTAIL_RUNTIME_ROBLOX_PERMISSIONS_BRIDGE_H_
#include <jni.h>
#include <memory>
#include <mutex>
#include "mocktail/status.h"
#include "runtime/roblox_game_session_native_adapter.h"
#include "runtime/roblox_message_bus_request_bridge.h"
namespace mocktail {
namespace runtime {
using RobloxPermissionsMessageBusSymbols = RobloxMessageBusSymbols;
using RobloxPermissionsMessageBusObjects = RobloxMessageBusObjects;
// Exact MessageBus JNI entrypoints used by the APK PermissionsProtocol.
struct RobloxPermissionsMessageBusSymbols {
jobject (*subscribe_request)(JNIEnv*, jobject, jstring, jstring, jobject,
jboolean) = nullptr;
void (*delete_connection)(JNIEnv*, jobject, jlong) = nullptr;
void (*publish_response)(JNIEnv*, jobject, jstring, jstring, jstring, jint,
jstring) = nullptr;
void (*set_async_handler)(JNIEnv*, jobject, jstring, jstring,
jobject) = nullptr;
void (*clear_handler)(JNIEnv*, jobject, jstring, jstring) = nullptr;
void (*call_response_handler)(JNIEnv*, jobject, jstring, jstring) = nullptr;
bool complete() const {
return subscribe_request && delete_connection && publish_response &&
set_async_handler && clear_handler && call_response_handler;
}
};
struct RobloxPermissionsMessageBusObjects {
jobject message_bus = nullptr;
void* context = nullptr;
jobject (*create_raw_callback)(void*, std::shared_ptr<void>,
void (*)(void*, JNIEnv*, jstring)) = nullptr;
void (*clear_raw_callback)(void*, jobject) = nullptr;
jobject (*create_async_handler)(void*, std::shared_ptr<void>,
void (*)(void*, JNIEnv*, jstring,
jstring)) = nullptr;
void (*clear_async_handler)(void*, jobject) = nullptr;
bool complete() const {
return message_bus && context && create_raw_callback &&
clear_raw_callback && create_async_handler && clear_async_handler;
}
};
// Installs both legacy protocol subscriptions and correlated async handlers
// before native bootstrap. MICROPHONE_ACCESS follows host capture
// configuration; LOCAL_NETWORK needs no extra runtime permission, matching the
// Android APK. No query/request opens an audio device, enables a disabled
// microphone, or changes Roblox account eligibility. Other capabilities remain
// unsupported.
class RobloxPermissionsBridge final {
public:
RobloxPermissionsBridge(JniEnvironmentProvider environment,
@@ -60,26 +14,12 @@ class RobloxPermissionsBridge final {
RobloxPermissionsMessageBusObjects objects,
bool microphone_enabled);
~RobloxPermissionsBridge();
RobloxPermissionsBridge(const RobloxPermissionsBridge&) = delete;
RobloxPermissionsBridge& operator=(const RobloxPermissionsBridge&) = delete;
Status Initialize();
Status Shutdown();
private:
struct State;
Status ShutdownLocked();
const JniEnvironmentProvider environment_;
const RobloxPermissionsMessageBusSymbols symbols_;
const RobloxPermissionsMessageBusObjects objects_;
const bool microphone_enabled_;
// Serializes lifecycle only. Native dispatch never holds this mutex.
std::mutex lifecycle_mutex_;
std::shared_ptr<State> state_;
std::unique_ptr<RobloxMessageBusRequestBridge> bridge_;
};
} // namespace runtime
} // namespace mocktail
#endif // MOCKTAIL_RUNTIME_ROBLOX_PERMISSIONS_BRIDGE_H_
#endif
@@ -1,6 +1,7 @@
#ifndef MOCKTAIL_RUNTIME_ROBLOX_PLATFORM_WEB_SYMBOLS_H_
#define MOCKTAIL_RUNTIME_ROBLOX_PLATFORM_WEB_SYMBOLS_H_
#include "runtime/roblox_account_bridge.h"
#include "runtime/roblox_browser_service_bridge.h"
#include "runtime/roblox_permissions_bridge.h"
#include "runtime/roblox_web_view_bridge.h"
@@ -15,6 +16,8 @@ struct RobloxPlatformWebSymbols {
RobloxWebViewMessageBusSymbols web_view;
RobloxBrowserServiceSymbols browser_service;
RobloxPermissionsMessageBusSymbols permissions;
// Optional and excluded from complete(); see RobloxExperienceComposition.
RobloxAccountProtocolSymbols account;
bool complete() const {
return web_view.complete() && browser_service.complete() &&
@@ -0,0 +1,35 @@
#ifndef MOCKTAIL_RUNTIME_ROBLOX_PRESENCE_RESOLVER_H_
#define MOCKTAIL_RUNTIME_ROBLOX_PRESENCE_RESOLVER_H_
#include <cstdint>
#include <string>
#include "mocktail/status.h"
namespace mocktail {
namespace runtime {
struct ResolvedFollowUserPlace {
int64_t place_id = 0;
std::string game_instance_id; // Roblox's "gameId" for the running session.
};
// Reads the session cookie mocktail already persists at
// ~/.local/share/mocktail/auth/roblox.cookie (or $XDG_DATA_HOME
// equivalent). Returns an empty string if no session is stored.
std::string ReadStoredRoblosecurityCookie();
// Calls Roblox's presence API to find what place/instance a user is
// currently in. `auth_cookie` is the runtime's already-authenticated
// .ROBLOSECURITY (or equivalent bearer token) — this function performs no
// authentication of its own and requires the runtime's validated session.
//
// Returns an error if the user is offline, has presence hidden, or is not
// in a joinable experience (e.g. in Studio, or privacy settings block it).
Status ResolveFollowUserPlace(int64_t user_id, const std::string& auth_cookie,
ResolvedFollowUserPlace* out);
} // namespace runtime
} // namespace mocktail
#endif // MOCKTAIL_RUNTIME_ROBLOX_PRESENCE_RESOLVER_H_
+6
View File
@@ -89,6 +89,12 @@ struct RobloxTextDisplaySink {
UpdateFn update = nullptr;
};
// Process-wide second observer of the focused TextBox display, called
// synchronously after the surface overlay with the same sensitivity contract.
// The VR keyboard uses it to show the text being typed in the headset, where
// the desktop overlay is not visible. Passing an empty sink clears it.
void SetRobloxTextDisplayMirror(RobloxTextDisplaySink sink);
struct RobloxTextSink {
using SyncFn = Status (*)(void* context, const char* utf8, std::size_t size,
int32_t cursor_utf16);
@@ -3,6 +3,7 @@
#include <cstdint>
#include <string>
#include <vector>
namespace mocktail {
namespace runtime {
@@ -10,8 +11,10 @@ namespace runtime {
// Android's RbxKeyboard resolves the Roblox Font enum through
// assets/android/fonts/font-mappings.json. The selected file lives below
// assets/content/fonts while legacy SourceSansPro faces live in assets/fonts.
// Emoji come from the color fonts Roblox ships next to the mapped faces.
struct RobloxTextFontSelection {
std::string primary_file;
std::vector<std::string> emoji_files;
float size_scale = 0.795F;
bool exact_mapping = false;
};
@@ -11,6 +11,8 @@
#include "mocktail/status.h"
#include "runtime/roblox_text_display_state.h"
struct TTF_Font;
namespace mocktail {
namespace runtime {
@@ -39,6 +41,8 @@ class RobloxTextSurfaceOverlay final {
const RobloxTextDisplayUpdate& update);
void ApplyUpdate(const RobloxTextDisplayUpdate& update);
Status RasterizeLocked();
Status OpenFontsLocked(std::int32_t font, float point_size);
void CloseFontsLocked();
void ClearFrameLocked();
void RecordFailureLocked(Status status);
@@ -46,10 +50,14 @@ class RobloxTextSurfaceOverlay final {
RobloxTextDisplayState state_;
RobloxTextOverlayViewport viewport_;
std::vector<std::uint8_t> rgba_;
std::vector<TTF_Font*> fonts_;
std::int32_t fonts_font_ = 0;
float fonts_point_size_ = 0.0F;
std::uint64_t state_revision_ = 0;
std::uint64_t raster_revision_ = 0;
Status failure_;
bool initialized_ = false;
bool ttf_initialized_ = false;
bool failure_logged_ = false;
bool ready_logged_ = false;
};
+61
View File
@@ -13,6 +13,8 @@
#include <mutex>
#include <optional>
#include <string>
#include <string_view>
#include <type_traits>
#include "mocktail/status.h"
#include "runtime/roblox_game_session_native_adapter.h"
@@ -30,6 +32,9 @@ inline constexpr char kRobloxOpenCaptchaViewNotification[] =
"OPEN_CAPTCHA_VIEW";
inline constexpr char kRobloxViewProfileNotification[] = "VIEW_PROFILE";
inline constexpr char kRobloxPurchaseRobuxNotification[] = "PURCHASE_ROBUX";
// Sent with a decimal user id when the app asks for a chat with that user.
inline constexpr char kRobloxLaunchConversationNotification[] =
"LAUNCH_CONVERSATION";
// Owned subset of WebViewProtocol's openWindow payload. The URL is copied
// before the MessageBus RawCallback returns, so no JNI local reference escapes.
@@ -42,6 +47,10 @@ struct RobloxWebViewOpenRequest {
std::optional<bool> show_domain_as_title;
std::optional<bool> back_button_visible;
std::optional<bool> hide_header;
// Set by the bridge, never parsed. The host closes an OPEN_CAPTCHA_VIEW
// window once its solved captcha has been forwarded, as Android's captcha
// activity finishes itself on success.
bool close_when_challenge_solved = false;
};
// WebViewProtocol.mutateWindow is a partial update. Field absence must remain
@@ -61,6 +70,9 @@ Status ParseRobloxWebViewMutationJson(const std::string& json,
RobloxWebViewMutationRequest* request);
Status ParseRobloxCaptchaOpenJson(const std::string& json,
RobloxWebViewOpenRequest* request);
// True for a page's Navigation.navigateToFeature command reporting a solved
// verification (see webview::IsRobloxChallengeSolvedFeature).
bool IsRobloxWebViewChallengeSuccess(std::string_view command);
Status ParseRobloxProfileViewUrl(const std::string& url,
RobloxWebViewOpenRequest* request);
Status ParseRobloxPurchaseRobuxNotification(const std::string& json,
@@ -106,8 +118,43 @@ using PublishWebViewRawFn = void (*)(JNIEnv *, jobject, jstring, jstring);
using BroadcastWebViewDataModelFocusFn = void (*)(JNIEnv *, jclass, jstring,
jstring, jstring);
using SignalWebViewJavascriptCallbackFn = void (*)(JNIEnv*, jclass, jstring);
// NativeGLInterface.nativeAppBridgeV2SendAppEventOnGameLoaded(namespace,
// detail, detailType).
using SendRobloxAppEventOnGameLoadedFn = void (*)(JNIEnv*, jclass, jstring,
jstring, jstring);
// NativeGLInterface.nativeAppBridgeV2SendAppEventOnAppReady(namespace, detail,
// detailType, appName). The engine holds the event until appName is ready.
using SendRobloxAppEventOnAppReadyFn = void (*)(JNIEnv*, jclass, jstring,
jstring, jstring, jstring);
using UpdateRobloxCookieSetHandlerFn =
void (*)(JNIEnv*, jobject, jobject);
// Roblox's counted handle to its Subsystem<IWebViewProtocol>. The protocol,
// and its handleJavascriptCallback subscriber, exist only while a handle is
// held. Roblox's handle type is not trivially destructible, so its getter
// returns it through the caller-supplied result slot (rdi on x86-64, x8 on
// AArch64). The user-provided destructor gives this type the same calling
// convention. It does not release the handle.
class RobloxWebViewProtocolHandle final {
public:
RobloxWebViewProtocolHandle() = default;
explicit RobloxWebViewProtocolHandle(void* holder) : holder_(holder) {}
RobloxWebViewProtocolHandle(const RobloxWebViewProtocolHandle&) = default;
RobloxWebViewProtocolHandle& operator=(const RobloxWebViewProtocolHandle&) =
default;
~RobloxWebViewProtocolHandle() {}
void* holder() const { return holder_; }
private:
void* holder_ = nullptr;
};
static_assert(sizeof(RobloxWebViewProtocolHandle) == sizeof(void*));
static_assert(!std::is_trivially_destructible_v<RobloxWebViewProtocolHandle>);
// Roblox's Subsystem<IWebViewProtocol> getter. It is not exported; the
// resolver finds it through the first call in
// WebViewProtocol.initializeAndroidWebViewProtocol.
using AcquireWebViewProtocolFn = RobloxWebViewProtocolHandle (*)();
struct RobloxWebViewMessageBusSymbols {
GetWebViewOpenWindowIdFn get_open_window_id = nullptr;
@@ -129,6 +176,11 @@ struct RobloxWebViewMessageBusSymbols {
GetWebViewCloseWindowIdFn get_close_window_id = nullptr;
SignalWebViewJavascriptCallbackFn signal_javascript_callback = nullptr;
UpdateRobloxCookieSetHandlerFn update_cookie_set_handler = nullptr;
// Optional: found by code contract, not by symbol name.
AcquireWebViewProtocolFn acquire_web_view_protocol = nullptr;
// Optional: without them LAUNCH_CONVERSATION is reported as unavailable.
SendRobloxAppEventOnGameLoadedFn send_app_event_on_game_loaded = nullptr;
SendRobloxAppEventOnAppReadyFn send_app_event_on_app_ready = nullptr;
bool complete() const {
return get_open_window_id != nullptr &&
@@ -232,11 +284,14 @@ private:
kDataModelUnfocused,
kDataModelFocused,
kMessageBusWindowClose,
kStartConversationWithUser,
};
struct PendingHostWindowEvent {
HostWindowEventType type = HostWindowEventType::kDataModelUnfocused;
uint64_t generation = 0;
// Decimal user id of a kStartConversationWithUser event.
std::string user_id;
};
Status CheckJniException(JNIEnv *env, const char *operation) const;
@@ -245,6 +300,8 @@ private:
Status DispatchOpenRequest(RobloxWebViewOpenRequest request,
OpenSource source);
void QueueHostWindowEvent(HostWindowEventType type, uint64_t generation);
void QueueStartConversation(std::string user_id);
Status SendStartConversationEvents(JNIEnv* env, const std::string& user_id);
Status BeginDispatch();
void EndDispatch();
static void RawMessageCallback(void *context, JNIEnv *env, jstring message);
@@ -287,6 +344,10 @@ private:
std::array<std::shared_ptr<RawCallbackTarget>, kSubscriptionCount>
raw_callback_targets_{};
std::shared_ptr<HostWindowCloseTarget> host_window_close_target_;
// The reference this counts is kept for the process lifetime, as Roblox's
// Android app start keeps its own. Neither Shutdown nor destruction
// releases it.
RobloxWebViewProtocolHandle web_view_protocol_handle_;
std::size_t in_flight_dispatches_ = 0;
bool initializing_ = false;
bool shutting_down_ = false;
@@ -8,6 +8,7 @@
#include "mocktail/status.h"
#include "runtime/roblox_input_native_adapter.h"
#include "runtime/roblox_text_surface_overlay.h"
#include "runtime/roblox_xr_controller_input.h"
namespace mocktail {
namespace runtime {
@@ -35,20 +36,40 @@ class RobloxWindowInputRuntime final {
Status QueryCurrentTextBoxInfo(RobloxNativeTextBoxInfoQueryResult* result);
Status UpdateTextFocusProperties(uint64_t generation,
const RobloxTextFocusProperties& properties);
// Feeds a synthetic platform event through the same authoritative router as
// real SDL events. Used by the XR controller delivery path so tracked
// controllers reuse the verified gamepad route (single event source, router
// deduplication and focus gating) instead of a second JNI input path.
void InjectPlatformEvent(const platform::PlatformEvent& event);
RobloxInputSnapshot Snapshot() const;
void SetRawMouseEnabled(bool enabled);
// Drains one batch of XR tracked-controller input into the same router.
// Called once per main-loop iteration on the input thread; inert in non-VR
// builds or while no OpenXR controller layer is active.
void DrainXrControllers();
// Routes an engine/experience vibration request to the active XR hand.
void RequestXrControllerHaptics(int hand, float amplitude,
std::uint64_t duration_ns, float frequency_hz);
void StopXrControllerHaptics(int hand);
private:
static void PlatformEventCallback(void* context,
const platform::PlatformEvent& event);
static bool MouseLockQueryCallback(void* context, bool* locked_center);
static void GamepadEventCallback(void* context,
const platform::PlatformEvent& event);
static void XrControllerEventCallback(void* context,
const platform::PlatformEvent& event);
RobloxTextSurfaceOverlay text_surface_overlay_;
std::unique_ptr<platform::TextClipboard> text_clipboard_;
RobloxInputRuntime runtime_;
platform::SdlGamepadManager gamepads_;
// XR tracked-controller producer. Feeds the same runtime_ router as SDL so
// there is exactly one authoritative input route.
RobloxXrControllerInput xr_controllers_;
bool xr_controllers_initialized_ = false;
bool observer_registered_ = false;
bool mouse_lock_query_registered_ = false;
bool initialized_ = false;
@@ -0,0 +1,100 @@
#ifndef MOCKTAIL_RUNTIME_ROBLOX_XR_CONTROLLER_INPUT_H_
#define MOCKTAIL_RUNTIME_ROBLOX_XR_CONTROLLER_INPUT_H_
#include <cstdint>
#include "mocktail/platform/platform_runtime.h"
#include "mocktail/status.h"
#include "mocktail/vr/xr_controller_abi.h"
namespace mocktail {
namespace runtime {
// Delivers OpenXR tracked-controller input through the one authoritative
// Roblox input route (RobloxInputRouter::HandleEvent), the same verified path
// physical SDL gamepads use. Because both sources converge on that single
// route, the router's own level deduplication, focus gating and
// release-on-disconnect logic apply unchanged and an XR controller can never
// double-fire alongside a physical gamepad.
//
// Both hands are merged into ONE synthetic gamepad: the left hand supplies
// LEFTX/LEFTY, the left shoulder (squeeze) and left stick click plus X/Y, the
// right hand supplies RIGHTX/RIGHTY, the right shoulder, right stick click and
// A/B. That matches how Roblox reads VR controller buttons through
// GamepadService, and it means the guest sees a single normal controller
// rather than two competing devices.
//
// The XR backend is resolved through dlsym so this module links and runs in
// non-VR builds and while no OpenXR backend is armed; then every drain is a
// no-op. No OpenXR headers are required here.
class RobloxXrControllerInput final {
public:
using EventSink = void (*)(void *context,
const platform::PlatformEvent &event);
// Synthetic device identity. Deliberately far outside the SDL joystick id
// range so it can never collide with a physical controller.
static constexpr std::int64_t kInstanceLeft =
static_cast<std::int64_t>(0x7FFFFFFFFFFFFFF0LL);
RobloxXrControllerInput() = default;
~RobloxXrControllerInput() = default;
RobloxXrControllerInput(const RobloxXrControllerInput &) = delete;
RobloxXrControllerInput &operator=(const RobloxXrControllerInput &) = delete;
// Resolves the XR controller ABI. Returns false (and stays inert) when the
// symbols are absent, which is the normal non-VR or no-backend case; that is
// not an error condition.
bool Initialize(EventSink sink, void *context);
// Test/injection seam: binds explicit ABI function pointers instead of
// resolving them through dlsym. A null pop makes the module inert, matching
// the non-VR case. Production callers use Initialize().
bool InitializeWithAbi(EventSink sink, void *context,
MocktailVrControllerPopFn pop,
MocktailVrControllerHapticsFn haptics,
MocktailVrControllerStopHapticsFn stop_haptics);
void Shutdown();
bool active() const { return pop_ != nullptr; }
// Drains every queued delivery and forwards it as platform events. Call once
// per main-loop iteration, after window::PumpEvents(), on the same thread
// that delivers SDL input. Bounded: drains at most kMaxDrainPerPump batches.
void Drain();
void ResendState() { force_resync_ = true; }
// Forwards a vibration request to the XR hand output. hand: 0 left, 1 right.
// No-op when the XR layer is inactive. amplitude is clamped by the backend.
void RequestHaptics(int hand, float amplitude, std::uint64_t duration_ns,
float frequency_hz);
void StopHaptics(int hand);
// Last observed state, for diagnostics/tests.
bool connected() const { return connected_; }
std::uint64_t delivered_events() const { return delivered_events_; }
std::uint64_t resyncs() const { return resyncs_; }
private:
static constexpr int kMaxDrainPerPump = 4;
void EmitConnection(bool connected);
void EmitButton(int sdl_button, bool pressed);
void EmitAxis(int sdl_axis, std::int16_t value);
void *context_ = nullptr;
EventSink sink_ = nullptr;
void *pop_ = nullptr;
void *haptics_ = nullptr;
void *stop_haptics_ = nullptr;
bool force_resync_ = false;
std::uint64_t generation_ = 0;
bool connected_ = false;
bool initialized_ = false;
std::uint64_t delivered_events_ = 0;
std::uint64_t resyncs_ = 0;
// Last delivered button levels, for resync and to avoid redundant events.
bool last_buttons_[16] = {};
};
} // namespace runtime
} // namespace mocktail
#endif // MOCKTAIL_RUNTIME_ROBLOX_XR_CONTROLLER_INPUT_H_
+43 -2
View File
@@ -16,6 +16,15 @@
namespace mocktail {
namespace runtime {
// VR is opt-in in every build (--vr or vr.enabled: true). A VR-capable build
// must not try to reach an OpenXR runtime on a normal desktop launch.
inline constexpr bool kDefaultVrEnabled = false;
// vr.mode: on and off force VR or the desktop. auto starts in VR only when
// SteamVR is running with a headset, which main() checks before launch.
enum class VrMode { kOff, kOn, kAuto };
const char* VrModeName(VrMode mode);
enum class GraphicsBackend {
kAuto,
kSystem,
@@ -51,14 +60,27 @@ struct NetworkProxyConfig {
int port = 0;
};
// Text and image fields expand {place_name}, {place_icon}, and
// {creator_name}. A field that expands an empty placeholder, or renders
// empty, is left out of the activity.
struct DiscordRpcTextConfig {
std::string title = "Roblox";
std::string browsing = "Browsing experiences";
std::string joining = "Joining an experience";
std::string playing = "{place_name}";
std::string state = "Playing Roblox";
std::string playing = "Playing {place_name}";
std::string state = "by {creator_name}";
std::string unknown_place = "Unknown experience";
};
// Images are an https URL or a Discord application asset key. Discord shows
// the small image only next to a large one.
struct DiscordRpcImagesConfig {
std::string large = "{place_icon}";
std::string large_text = "{place_name}";
std::string small = "roblox_small";
std::string small_text = "Roblox";
};
struct DiscordRpcConfig {
bool enabled = true;
bool show_place_name = true;
@@ -68,6 +90,7 @@ struct DiscordRpcConfig {
std::string join_button_label = "Join Server";
std::string application_id;
DiscordRpcTextConfig text;
DiscordRpcImagesConfig images;
};
std::optional<NetworkProxyConfig> ParseNetworkProxyConfig(
@@ -82,6 +105,10 @@ class RuntimeConfig {
static RuntimeConfig FromEnvironment(const Environment& environment);
bool headless() const { return headless_; }
bool vr_enabled() const { return vr_enabled_; }
// MOCKTAIL_VR_ENABLED (--vr, --no-vr) wins over MOCKTAIL_VR_MODE.
VrMode vr_mode() const { return vr_mode_; }
bool vr_valid() const { return vr_valid_; }
const std::filesystem::path& roblox_library_path() const {
return roblox_library_path_;
}
@@ -107,6 +134,15 @@ class RuntimeConfig {
return roblox_http_user_agent_;
}
const FrameRatePolicy& frame_rate() const { return frame_rate_; }
// Replaces frame_rate() while VR is enabled. Defaults to unlimited so the
// OpenXR runtime's xrWaitFrame paces Roblox at the headset refresh rate.
const FrameRatePolicy& vr_frame_rate() const { return vr_frame_rate_; }
// vr.anti_aliasing: fxaa, smaa or off. The VR backend reads it from the
// exported MOCKTAIL_VR_ANTI_ALIASING.
const std::string& vr_anti_aliasing() const { return vr_anti_aliasing_; }
const FrameRatePolicy& active_frame_rate() const {
return vr_enabled_ ? vr_frame_rate_ : frame_rate_;
}
const std::string& vsync_mode() const { return vsync_mode_; }
const PerformancePolicy& performance() const { return performance_; }
const std::string& audio_output_device() const {
@@ -147,6 +183,9 @@ class RuntimeConfig {
private:
bool headless_ = false;
bool vr_enabled_ = kDefaultVrEnabled;
VrMode vr_mode_ = kDefaultVrEnabled ? VrMode::kOn : VrMode::kOff;
bool vr_valid_ = true;
std::filesystem::path roblox_library_path_ = "rbx_bin/libroblox.so";
GraphicsBackend graphics_backend_ = GraphicsBackend::kVulkan;
std::string graphics_backend_name_ = "direct-vulkan";
@@ -157,6 +196,8 @@ class RuntimeConfig {
bool device_profile_valid_ = true;
std::optional<std::string> roblox_http_user_agent_;
FrameRatePolicy frame_rate_;
FrameRatePolicy vr_frame_rate_{FrameRateLimitMode::kUnlimited, 0};
std::string vr_anti_aliasing_ = "fxaa";
std::string vsync_mode_ = "auto";
PerformancePolicy performance_;
std::string audio_output_device_ = "default";
+5 -1
View File
@@ -14,7 +14,7 @@ struct ActivePayloadPaths {
std::filesystem::path root;
std::filesystem::path roblox_library;
std::filesystem::path base_apk;
std::filesystem::path x86_64_split_apk;
std::filesystem::path arch_split_apk;
std::filesystem::path assets_content;
std::filesystem::path compatibility_manifest;
std::filesystem::path host_abi_profile;
@@ -67,6 +67,9 @@ class RuntimePaths {
return active_payload_manifest_;
}
const std::filesystem::path& cookie_file() const { return cookie_file_; }
const std::filesystem::path& asset_overlay_dir() const {
return asset_overlay_dir_;
}
bool use_real_sober_paths() const { return use_real_sober_paths_; }
std::filesystem::path DefaultAssetPath() const;
@@ -99,6 +102,7 @@ class RuntimePaths {
std::filesystem::path config_file_;
std::filesystem::path active_payload_manifest_;
std::filesystem::path cookie_file_;
std::filesystem::path asset_overlay_dir_;
bool use_real_sober_paths_ = false;
};
+4
View File
@@ -11,6 +11,10 @@ struct HostAbiDerivationOptions {
std::filesystem::path reference_library;
std::filesystem::path reference_profile;
std::vector<std::filesystem::path> reference_compatibility_manifests;
// Optional libroblox.so of another build that the compatibility manifests
// describe. It supplies the FMOD output-device anchors when the manifests
// have none for the reference build.
std::filesystem::path runtime_fallback_library;
std::filesystem::path candidate_payload_directory;
std::filesystem::path output_directory;
};
+10 -1
View File
@@ -4,6 +4,7 @@
#include <array>
#include <cstdint>
#include <filesystem>
#include <functional>
#include <string>
#include <vector>
@@ -33,11 +34,18 @@ struct PayloadGarbageResult {
explicit operator bool() const { return error.empty(); }
};
// Optional per-store fault hook for atomic manifest writes. Returning true
// makes the selected operation fail through its normal cleanup path.
enum class ManifestWritePhase { kCreate, kWrite, kFileFsync, kRename };
using ManifestWriteFault =
std::function<bool(const std::filesystem::path&, ManifestWritePhase)>;
class PayloadStore final {
public:
PayloadStore(std::filesystem::path root,
std::filesystem::path compatibility_manifest,
std::filesystem::path runtime_binary = {});
std::filesystem::path runtime_binary = {},
ManifestWriteFault manifest_write_fault = {});
PayloadStoreResult Stage(const std::filesystem::path& prepared_payload);
PayloadStoreResult Promote(std::string_view payload_id);
@@ -60,6 +68,7 @@ class PayloadStore final {
std::filesystem::path root_;
std::filesystem::path compatibility_manifest_;
std::filesystem::path runtime_binary_;
ManifestWriteFault manifest_write_fault_;
};
} // namespace mocktail::update
+13 -2
View File
@@ -1,7 +1,10 @@
#ifndef MOCKTAIL_UPDATE_READINESS_CANARY_H_
#define MOCKTAIL_UPDATE_READINESS_CANARY_H_
#include <spawn.h>
#include <filesystem>
#include <functional>
#include <string>
#include <string_view>
@@ -22,6 +25,12 @@ std::string_view CanaryGraphicsBackendName(CanaryGraphicsBackend backend);
bool ParseCanaryGraphicsBackend(std::string_view name,
CanaryGraphicsBackend* backend);
// Optional per-request replacement for the POSIX spawn operation.
// It follows posix_spawn return/ownership semantics; empty uses posix_spawn.
using CanarySpawn =
std::function<int(pid_t*, const char*, const posix_spawn_file_actions_t*,
const posix_spawnattr_t*, char* const[], char* const[])>;
struct CanaryOptions {
std::filesystem::path runtime_binary;
std::filesystem::path payload_directory;
@@ -29,9 +38,9 @@ struct CanaryOptions {
std::filesystem::path host_abi_profile;
std::filesystem::path cache_root;
std::filesystem::path state_root;
CanaryGraphicsBackend graphics_backend =
CanaryGraphicsBackend::kDirectVulkan;
CanaryGraphicsBackend graphics_backend = CanaryGraphicsBackend::kDirectVulkan;
int timeout_seconds = 150;
CanarySpawn spawn;
bool probation_candidate() const { return !host_abi_profile.empty(); }
};
@@ -39,6 +48,8 @@ struct CanaryOptions {
struct CanaryResult {
std::filesystem::path log_path;
int exit_code = -1;
// Direct posix_spawn return code; zero when spawn was not attempted.
int spawn_error = 0;
std::string error;
explicit operator bool() const {
+3
View File
@@ -26,6 +26,8 @@ struct UpdatePaths {
// build the single file happens to describe.
std::filesystem::path host_abi_reference_profile;
std::filesystem::path runtime_binary;
// Optional packaging wrapper; runtime_binary identifies the actual ELF.
std::filesystem::path runtime_launcher;
};
struct UpdateRequest {
@@ -38,6 +40,7 @@ struct UpdateRequest {
CanaryGraphicsBackend canary_graphics_backend =
CanaryGraphicsBackend::kDirectVulkan;
int progress_fd = -1;
CanarySpawn canary_spawn;
};
struct UpdateResult {
@@ -0,0 +1,19 @@
#ifndef MOCKTAIL_WEBVIEW_ROBLOX_CHALLENGE_FEATURES_H_
#define MOCKTAIL_WEBVIEW_ROBLOX_CHALLENGE_FEATURES_H_
#include <string_view>
namespace mocktail::webview {
// Navigation.navigateToFeature features a Roblox verification page sends when
// the user has solved it. The captcha page sends CaptchaSuccess; the generic
// challenge page (challenge/cdn/hybrid) sends challengeCompleted. The WebView
// helper and the runtime both classify page events with this list.
inline bool IsRobloxChallengeSolvedFeature(std::string_view feature) {
return feature == "CaptchaSuccess" || feature == "challengeCompleted" ||
feature == "ChallengeCompleted";
}
} // namespace mocktail::webview
#endif // MOCKTAIL_WEBVIEW_ROBLOX_CHALLENGE_FEATURES_H_
+5
View File
@@ -13,6 +13,11 @@ pre-commit:
desktop-entry:
glob: "*.desktop"
run: desktop-file-validate {staged_files}
changelog-width:
glob: "CHANGELOG.md"
run: >-
git diff --cached -U0 -- CHANGELOG.md |
awk '/^\+[^+]/ && length($0) > 111 { print "CHANGELOG.md: added line over 110 characters: " substr($0, 2); bad = 1 } END { exit bad }'
pre-push:
commands:
@@ -0,0 +1,10 @@
# Allowed reduced aarch64 packages from pkgforge-dev/archlinux-pkgs-debloated.
# The installer verifies the rolling release asset IDs and SHA256 digests.
ffmpeg-mini-aarch64.pkg.tar.zst
gdk-pixbuf2-mini-aarch64.pkg.tar.xz
gtk4-mini-aarch64.pkg.tar.zst
icu-mini-aarch64.pkg.tar.zst
librsvg-mini-aarch64.pkg.tar.zst
libxml2-mini-aarch64.pkg.tar.zst
mesa-mini-aarch64.pkg.tar.zst
opus-mini-aarch64.pkg.tar.zst
+1
View File
@@ -7,6 +7,7 @@ MOCKTAIL_PORTABLE_MODE=standalone
MOCKTAIL_RUNTIME_LIBRARY_DIR=${SHARUN_DIR}/lib/mocktail
MOCKTAIL_BIN=${SHARUN_DIR}/bin/mocktail
MOCKTAIL_UPDATE_CANARY_BIN=${SHARUN_DIR}/bin/mocktail
MOCKTAIL_UPDATE_RUNTIME_BINARY=${SHARUN_DIR}/shared/bin/mocktail
MOCKTAIL_UPDATE_HELPER=${SHARUN_DIR}/bin/mocktail_updater
MOCKTAIL_WEBVIEW_HELPER=${SHARUN_DIR}/bin/mocktail_webview_helper
MOCKTAIL_FAILURE_DIALOG_HELPER=${SHARUN_DIR}/bin/mocktail_failure_dialog
+8 -5
View File
@@ -1,12 +1,14 @@
# Maintainer: komaruworld <rtysus228@gmail.com>
# Maintainer: vii <coderdayton14@gmail.com>
pkgname=mocktail
pkgver=1.0.3
pkgrel=2
pkgname=nightcap
pkgver=0.3.4
pkgrel=1
pkgdesc='Android x86-64 Roblox compatibility runtime for Linux'
arch=('x86_64')
url='https://github.com/komaruworld/mocktail'
url='https://github.com/CoderDayton/nightcap'
license=('Apache-2.0')
provides=('mocktail')
conflicts=('mocktail')
depends=(
'capstone'
'curl'
@@ -16,6 +18,7 @@ depends=(
'libelf'
'libglvnd'
'libplacebo'
'libpng'
'libutf8proc'
'libyaml'
'minizip'
@@ -1,8 +1,8 @@
pkgbase = mocktail-bin
pkgbase = nightcap-bin
pkgdesc = Android x86-64 Roblox compatibility runtime for Linux (prebuilt)
pkgver = 1.0.4
pkgver = 0.3.4
pkgrel = 1
url = https://github.com/komaruworld/mocktail
url = https://github.com/CoderDayton/nightcap
arch = x86_64
license = Apache-2.0
depends = capstone
@@ -17,6 +17,7 @@ pkgbase = mocktail-bin
depends = libelf
depends = libglvnd
depends = libplacebo
depends = libpng
depends = libsoup3
depends = libutf8proc
depends = libyaml
@@ -29,12 +30,14 @@ pkgbase = mocktail-bin
depends = zlib
optdepends = gamemode: Feral GameMode integration
provides = mocktail
provides = nightcap
conflicts = mocktail
conflicts = mocktail-git
noextract = mocktail-1.0.4-x86_64.pkg.tar.zst
conflicts = nightcap
conflicts = nightcap-git
noextract = nightcap-0.3.4-1-x86_64.pkg.tar.zst
options = !debug
options = !strip
source = mocktail-1.0.4-x86_64.pkg.tar.zst::https://github.com/komaruworld/mocktail/releases/download/1.0.4/mocktail-1.0.4-x86_64.pkg.tar.zst
sha256sums = d5cc7216e88fd2889369e8eb7fba4276b2746c2b084dce566aaaa7505bc2ffb7
source = nightcap-0.3.4-1-x86_64.pkg.tar.zst::https://github.com/CoderDayton/nightcap/releases/download/v0.3.4/nightcap-0.3.4-1-x86_64.pkg.tar.zst
sha256sums = SKIP
pkgname = mocktail-bin
pkgname = nightcap-bin
@@ -1,11 +1,11 @@
# Maintainer: komaruworld <rtysus228 at gmail dot com>
# Maintainer: vii <coderdayton14 at gmail dot com>
pkgname=mocktail-bin
pkgver=1.0.4
pkgname=nightcap-bin
pkgver=0.3.4
pkgrel=1
pkgdesc='Android x86-64 Roblox compatibility runtime for Linux (prebuilt)'
arch=('x86_64')
url='https://github.com/komaruworld/mocktail'
url='https://github.com/CoderDayton/nightcap'
license=('Apache-2.0')
depends=(
'capstone'
@@ -20,6 +20,7 @@ depends=(
'libelf'
'libglvnd'
'libplacebo'
'libpng'
'libsoup3'
'libutf8proc'
'libyaml'
@@ -32,17 +33,18 @@ depends=(
'zlib'
)
optdepends=('gamemode: Feral GameMode integration')
provides=('mocktail')
conflicts=('mocktail' 'mocktail-git')
provides=('mocktail' 'nightcap')
conflicts=('mocktail' 'nightcap' 'nightcap-git')
options=('!debug' '!strip')
# Release asset names do not include the AUR package release.
_archive="mocktail-${pkgver}-${CARCH}.pkg.tar.zst"
source=("${_archive}::https://github.com/komaruworld/mocktail/releases/download/${pkgver}/${_archive}")
# The release asset carries the pacman package release built by release.yml.
_archive="nightcap-${pkgver}-1-${CARCH}.pkg.tar.zst"
source=("${_archive}::https://github.com/CoderDayton/nightcap/releases/download/v${pkgver}/${_archive}")
noextract=("${_archive}")
sha256sums=('d5cc7216e88fd2889369e8eb7fba4276b2746c2b084dce566aaaa7505bc2ffb7')
# Replace with the checksum published beside the release asset.
sha256sums=('SKIP')
package() {
bsdtar -xf "${srcdir}/${_archive}" -C "${pkgdir}" usr
install -Dm644 "${pkgdir}/usr/share/licenses/mocktail/LICENSE" \
install -Dm644 "${pkgdir}/usr/share/licenses/nightcap/LICENSE" \
"${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
}
@@ -1,8 +1,8 @@
pkgbase = mocktail
pkgbase = nightcap-git
pkgdesc = Android x86-64 Roblox compatibility runtime for Linux
pkgver = 1.0.4
pkgver = 0.3.1.r0.g0000000
pkgrel = 1
url = https://github.com/komaruworld/mocktail
url = https://github.com/CoderDayton/nightcap
arch = x86_64
license = Apache-2.0
makedepends = cmake>=3.20
@@ -24,6 +24,7 @@ pkgbase = mocktail
depends = libelf
depends = libglvnd
depends = libplacebo
depends = libpng
depends = libsoup3
depends = libutf8proc
depends = libyaml
@@ -35,11 +36,13 @@ pkgbase = mocktail
depends = webkitgtk-6.0
depends = zlib
optdepends = gamemode: Feral GameMode integration
conflicts = mocktail-bin
conflicts = mocktail-git
provides = mocktail
provides = nightcap
conflicts = mocktail
conflicts = nightcap
conflicts = nightcap-bin
options = !debug
options = !lto
source = mocktail::git+https://github.com/komaruworld/mocktail.git#tag=1.0.4
source = nightcap::git+https://github.com/CoderDayton/nightcap.git#branch=main
sha256sums = SKIP
pkgname = mocktail
pkgname = nightcap-git
@@ -1,11 +1,11 @@
# Maintainer: komaruworld <rtysus228 at gmail dot com>
# Maintainer: vii <coderdayton14 at gmail dot com>
pkgname=mocktail-git
pkgver=1.0.2.r24.g9bdd737
pkgname=nightcap-git
pkgver=0.3.1.r0.g0000000
pkgrel=1
pkgdesc='Android x86-64 Roblox compatibility runtime for Linux'
arch=('x86_64')
url='https://github.com/komaruworld/mocktail'
url='https://github.com/CoderDayton/nightcap'
license=('Apache-2.0')
depends=(
'capstone'
@@ -20,6 +20,7 @@ depends=(
'libelf'
'libglvnd'
'libplacebo'
'libpng'
'libsoup3'
'libutf8proc'
'libyaml'
@@ -41,31 +42,30 @@ makedepends=(
'vulkan-headers'
)
optdepends=('gamemode: Feral GameMode integration')
provides=('mocktail')
conflicts=('mocktail' 'mocktail-bin')
provides=('mocktail' 'nightcap')
conflicts=('mocktail' 'nightcap' 'nightcap-bin')
options=('!debug')
source=(
'mocktail::git+https://github.com/komaruworld/mocktail.git#branch=main'
'nightcap::git+https://github.com/CoderDayton/nightcap.git#branch=main'
)
sha256sums=(
'SKIP'
)
pkgver() {
cd mocktail
cd nightcap
local version
version="$(sed -n \
's/^[[:space:]]*VERSION[[:space:]]\+\([0-9][0-9.]*\).*/\1/p' \
CMakeLists.txt | head -n 1)"
version="$(git describe --tags --abbrev=0 --match='v[0-9]*' 2>/dev/null)"
version="${version#v}"
printf '%s.r%s.g%s' \
"${version}" \
"${version:-0.0.0}" \
"$(git rev-list --count HEAD)" \
"$(git rev-parse --short=7 HEAD)"
}
prepare() {
cd mocktail
cd nightcap
sed -i \
's|add_subdirectory(third_party/Vulkan-Headers EXCLUDE_FROM_ALL)|find_package(VulkanHeaders CONFIG REQUIRED)|' \
@@ -73,7 +73,7 @@ prepare() {
}
build() {
cmake -S mocktail -B build -G Ninja \
cmake -S nightcap -B build -G Ninja \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX=/usr \
-DCMAKE_INSTALL_LIBDIR=lib \
@@ -86,6 +86,6 @@ build() {
package() {
DESTDIR="${pkgdir}" cmake --install build
install -Dm644 mocktail/LICENSE \
install -Dm644 nightcap/LICENSE \
"${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
}
Loaded 100 of 347 files, more files were not shown because too many files have changed in this diff. Show more