Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,7 @@ add_library(mocktail_runtime STATIC
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
Expand Down
24 changes: 21 additions & 3 deletions include/mocktail/vr/openxr_backend.h
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,9 @@ class OpenXrBackend final {
// 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_;

Expand Down Expand Up @@ -492,11 +495,26 @@ OpenXrBackend* ActiveVrBackend();

// Paths of the shared objects mapped into the process.
std::vector<std::string> LoadedSharedObjects();
// Keeps every shared object loaded since `before` mapped until exit and
// returns the paths it pinned.
std::vector<std::string> PinSharedObjectsLoadedSince(
// 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_
3 changes: 3 additions & 0 deletions include/mocktail/vr/roblox_vr_device_bridge.h
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ class RobloxVrDeviceBridge final {

// 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);
Expand Down
2 changes: 2 additions & 0 deletions include/runtime/roblox_experience_composition.h
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
#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"
Expand Down Expand Up @@ -260,6 +261,7 @@ class RobloxExperienceComposition final {
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_;
Expand Down
47 changes: 47 additions & 0 deletions include/runtime/roblox_haptic_protocol_bridge.h
Original file line number Diff line number Diff line change
@@ -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
74 changes: 74 additions & 0 deletions scripts/find_roblox_vr_offsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,13 @@
EYE_RESOURCE_LABEL = b"VR Eye"
EMULATOR_FLAG_NAME = b"DebugEnableVREmulator"
HAND_PITCH_FLAG_NAME = b"NewVRSystemHandPitch"
# Boolean fast flags Nightcap turns on in VR, keyed by contract field.
HAPTIC_FLAG_NAMES = {
"keep_haptics_flag_rva": b"KeepHapticsOnMobileWhenGamepadConnected",
"vr_haptics_flag_rva": b"VRHaptics",
"support_generic_haptics_flag_rva": b"SupportGenericHapticsOnVR",
"fix_register_motors_flag_rva": b"FixRegisterMotors",
}

OBJECT_TYPE_OFFSET = 0x10
OBJECT_TYPE_DEBUG_DEVICE = 6
Expand Down Expand Up @@ -948,6 +955,69 @@ def extract_hand_pitch(instructions: Sequence[Any],
return None


def extract_bool_flag_storage(instructions: Sequence[Any],
flag_name_rva: int) -> int | None:
"""Value byte of a boolean flag: a byte or word store to it, then
lea reg, [name]; mov [storage+8], reg."""
for index, instruction in enumerate(instructions):
operands = instruction.operands
if (instruction.mnemonic != "lea" or len(operands) != 2 or
operands[0].type != capstone_x86.X86_OP_REG or
flag_name_rva not in rip_target(instruction) or
index + 1 >= len(instructions)):
continue
store = instructions[index + 1]
store_operands = store.operands
if (store.mnemonic != "mov" or len(store_operands) != 2 or
store_operands[0].type != capstone_x86.X86_OP_MEM or
store_operands[1].type != capstone_x86.X86_OP_REG or
store_operands[1].reg != operands[0].reg):
continue
name_slot = next(iter(rip_target(store)), None)
if name_slot is None:
continue
storage = name_slot - 8
for writer in instructions[max(0, index - 3):index]:
writer_operands = writer.operands
if (writer.mnemonic == "mov" and len(writer_operands) == 2 and
writer_operands[0].type == capstone_x86.X86_OP_MEM and
writer_operands[0].size in (1, 2) and
storage in rip_target(writer)):
return storage
return None


def find_bool_flags(image: ElfImage, table: FunctionTable,
disasm: Disassembler, names: dict[str, bytes],
evidence: list[str], warnings: list[str]) -> dict[str, int]:
"""Value-byte RVA per field; 0 when the build lacks the flag."""
found: dict[str, int] = {}
for field, name in names.items():
label = name.decode("ascii")
try:
name_rva = image.find_alloc_string(name)
except AnalyzerError as error:
warnings.append(f"{label}: {error}; {field} left at 0")
found[field] = 0
continue
matches: set[int] = set()
for refs in find_text_refs(image, table, disasm, {name_rva}).values():
for _address, start, end in refs:
storage = extract_bool_flag_storage(disasm.function(start, end),
name_rva)
if storage is not None:
matches.add(storage)
if len(matches) != 1:
listed = ", ".join(f"0x{rva:x}" for rva in sorted(matches))
raise AnalyzerError(f"{label} flag storage is not unique: [{listed}]")
storage = matches.pop()
image.require_writable_rva(storage, 16)
evidence.append(f"{label}: value byte with name pointer at storage+8"
f" -> 0x{storage:x}")
found[field] = storage
return found


def match_pointer_getter(instructions: Sequence[Any], selector_offset: int,
base_offset: int) -> int | None:
"""World-space pointer getter: UserCFrame copy + workspace/camera path.
Expand Down Expand Up @@ -1374,6 +1444,9 @@ def find_vr_offsets(elf_path: pathlib.Path) -> dict[str, Any]:
f"hand pitch: dword default {hand_pitch_default} degrees with name"
f" pointer at storage+8 -> 0x{hand_pitch_storage:x}")

haptic_flags = find_bool_flags(image, table, disasm, HAPTIC_FLAG_NAMES,
evidence, warnings)

getter_candidates: dict[int, tuple[int, int]] = {}
for hit, selector, base in scan_pointer_getter_sites(image):
try:
Expand Down Expand Up @@ -1462,6 +1535,7 @@ def find_vr_offsets(elf_path: pathlib.Path) -> dict[str, Any]:
f"0x{hand_pitch_storage:x}",
"new_vr_system_hand_pitch_default_degrees":
hand_pitch_default,
**{field: f"0x{rva:x}" for field, rva in haptic_flags.items()},
},
"evidence": evidence,
"warnings": warnings,
Expand Down
59 changes: 58 additions & 1 deletion src/legacy/legacy_runtime.cc
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
#include "runtime/roblox_capability_resolver.h"
#include "runtime/roblox_platform_web_symbols.h"
#include "runtime/roblox_experience_composition.h"
#include "runtime/roblox_haptic_protocol_bridge.h"
#include "runtime/roblox_game_session_native_adapter.h"
#include "runtime/roblox_window_input_runtime.h"
#include "runtime/roblox_text_input_jni_bridge.h"
Expand Down Expand Up @@ -214,6 +215,7 @@ using NativeSendGameLoadedFn = void (*)(JNIEnv*, jclass, jstring, jstring,
using NativePassSupportedRefreshRatesFn = void (*)(JNIEnv*, jclass,
jfloatArray);
using NativePassCurrentDisplayRefreshRateFn = void (*)(JNIEnv*, jclass, jfloat);
using NativeUpdateMobileHapticsStateFn = void (*)(JNIEnv*, jclass, jboolean, jstring);
using NativeSetStringParamFn = void (*)(JNIEnv*, jclass, jstring);
using NativeSetBaseUrlFn = void (*)(JNIEnv*, jclass, jstring, jstring);
using NativeSetTaskSchedulerBackgroundModeFn = void (*)(JNIEnv*, jclass,
Expand Down Expand Up @@ -381,6 +383,7 @@ struct EngineStartupContext {
NativePassSupportedRefreshRatesFn native_pass_supported_refresh_rates;
NativePassCurrentDisplayRefreshRateFn native_pass_current_display_refresh_rate;
NativeNoArgFn native_start_lua_app_dm;
NativeUpdateMobileHapticsStateFn native_update_mobile_haptics_state;
};


Expand Down Expand Up @@ -4314,6 +4317,23 @@ std::cerr << " [engine] nativeAppBridgeV2InitWithParams recovered\n"
std::cout << " [engine] updateAppUISizes returned\n" << std::flush;
}

if (context->native_update_mobile_haptics_state) {
env = ensure_env();
std::cout << " [engine] NativeGLInterface.nativeUpdateMobileHapticsState\n"
<< std::flush;
const std::string motors_json =
mocktail::runtime::RobloxHapticProtocolBridge::GetMotorsJson();
jstring motors_str = env->NewStringUTF(motors_json.c_str());
if (motors_str != nullptr) {
context->native_update_mobile_haptics_state(env, native_gl_class,
JNI_TRUE, motors_str);
env->DeleteLocalRef(motors_str);
}
std::cout
<< " [engine] NativeGLInterface.nativeUpdateMobileHapticsState returned\n"
<< std::flush;
}

int init_delay_ms = GetEnvInt("MOCKTAIL_APPBRIDGE_INIT_DELAY_MS", 0);
if (init_delay_ms > 0) {
std::cout << " [engine] wait after AppBridge init: " << init_delay_ms
Expand Down Expand Up @@ -4594,6 +4614,16 @@ std::cerr << " [engine] nativeAppBridgeV2InitWithParams recovered\n"
std::cout
<< " [engine] nativeAppBridgeV2SendAppEventOnGameLoaded returned\n"
<< std::flush;
if (context->native_update_mobile_haptics_state) {
const std::string motors_json =
mocktail::runtime::RobloxHapticProtocolBridge::GetMotorsJson();
jstring motors_str = env->NewStringUTF(motors_json.c_str());
if (motors_str != nullptr) {
context->native_update_mobile_haptics_state(env, native_gl_class,
JNI_TRUE, motors_str);
env->DeleteLocalRef(motors_str);
}
}
}
}

Expand Down Expand Up @@ -6082,6 +6112,8 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
eager_permissions_bridge;
std::unique_ptr<mocktail::runtime::RobloxCallProtocolBridge>
eager_call_protocol_bridge;
std::unique_ptr<mocktail::runtime::RobloxHapticProtocolBridge>
eager_haptic_protocol_bridge;
std::shared_ptr<ExperienceLifecycleTarget> experience_lifecycle_target;
std::shared_ptr<mocktail::runtime::RobloxWindowInputRuntime>
window_input_runtime;
Expand Down Expand Up @@ -6378,6 +6410,14 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
roblox_handle,
"Java_com_roblox_engine_jni_NativeGLInterface_"
"nativeAppBridgeV2SendAppEventOnGameLoaded"));
auto* native_update_mobile_haptics_state =
mocktail::runtime::RobloxHapticProtocolBridge::EnabledForThisSession()
? reinterpret_cast<NativeUpdateMobileHapticsStateFn>(
linker::ResolveSymbol(
roblox_handle,
"Java_com_roblox_engine_jni_NativeGLInterface_"
"nativeUpdateMobileHapticsState"))
: nullptr;
auto* native_set_asset_path =
reinterpret_cast<NativeSetStringParamFn>(linker::ResolveSymbol(
roblox_handle,
Expand Down Expand Up @@ -6456,6 +6496,10 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
<< reinterpret_cast<const void*>(native_send_app_ready) << '\n'
<< " native_send_game_loaded="
<< reinterpret_cast<const void*>(native_send_game_loaded) << '\n'
<< " native_update_mobile_haptics_state="
<< reinterpret_cast<const void*>(
native_update_mobile_haptics_state)
<< '\n'
<< " native_start_lua_app_dm="
<< reinterpret_cast<const void*>(native_start_lua_app_dm) << '\n'
<< " native_call_messages_from_main_thread="
Expand Down Expand Up @@ -6796,9 +6840,17 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
eager_call_protocol_bridge =
std::make_unique<mocktail::runtime::RobloxCallProtocolBridge>(
environment, symbols.permissions, objects);
if (mocktail::runtime::RobloxHapticProtocolBridge::
EnabledForThisSession()) {
eager_haptic_protocol_bridge =
std::make_unique<mocktail::runtime::RobloxHapticProtocolBridge>(
environment, symbols.permissions, objects);
}
status = eager_permissions_bridge->Initialize();
if (status.ok())
status = eager_call_protocol_bridge->Initialize();
if (status.ok() && eager_haptic_protocol_bridge != nullptr)
status = eager_haptic_protocol_bridge->Initialize();
env->DeleteLocalRef(bus);
if (!status.ok()) {
std::cerr << "[FATAL] GAME platform protocols did not initialize: "
Expand Down Expand Up @@ -6960,7 +7012,8 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
native_on_fragment_start,
native_pass_supported_refresh_rates,
native_pass_current_display_refresh_rate,
native_start_lua_app_dm};
native_start_lua_app_dm,
native_update_mobile_haptics_state};
if (game_session_runtime != nullptr) {
if (!game_present_observer.Register(
&mocktail::runtime::RobloxGameSessionRuntime::
Expand Down Expand Up @@ -7307,6 +7360,10 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
input_shutdown_completed = window_input_runtime->Shutdown().ok() &&
input_shutdown_completed;
}
if (eager_haptic_protocol_bridge != nullptr) {
(void)eager_haptic_protocol_bridge->Shutdown();
eager_haptic_protocol_bridge.reset();
}
if (eager_call_protocol_bridge != nullptr) {
(void)eager_call_protocol_bridge->Shutdown();
eager_call_protocol_bridge.reset();
Expand Down
4 changes: 4 additions & 0 deletions src/runtime/performance_policy.cc
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,10 @@ bool MergeVrClientSettingsOverrides(bool enabled, std::string_view base_json,
// Place the Avatar Editor character 6 studs in front of the VR camera
// instead of at the origin when IsVRAppBuild() is active.
overrides["FFlagFixVRAvatarPlacement"] = "True";
// Mobile suppresses vibration when a gamepad connects unless KeepHaptics
// is enabled; VR controllers connect as Gamepad1.
overrides["FFlagKeepHapticsOnMobileWhenGamepadConnected"] = "True";
overrides["FFlagVRHaptics"] = "True";
}
*merged_json = overrides.dump();
return true;
Expand Down
Loading