diff --git a/cmake/MocktailVR.cmake b/cmake/MocktailVR.cmake index 7c76c85..67fb47b 100644 --- a/cmake/MocktailVR.cmake +++ b/cmake/MocktailVR.cmake @@ -27,6 +27,7 @@ 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 diff --git a/config/mocktail.example.yaml b/config/mocktail.example.yaml index 66b1b79..3791983 100644 --- a/config/mocktail.example.yaml +++ b/config/mocktail.example.yaml @@ -53,15 +53,14 @@ graphics: # vsync: off # vr: - # Boolean (default: false). Same as launching with --vr. - # Experimental VR for verified builds 2998 and 3092. Mocktail initializes - # the guest DebugDeviceVR eye resources (two - # distinct 500x500 eye framebuffers), the Roblox renderer draws both eyes - # every frame, and the frames are submitted to the active OpenXR runtime as - # a stereo projection layer with head pose (runtime-tracked, or scripted for - # desktop testing). Controllers use the native VR bridge. Standalone OpenXR - # test scene: mocktail-vr-probe --scene. Use --no-vr to launch normally. - # enabled: false + # 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 # 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 diff --git a/include/mocktail/vr/headset_detection.h b/include/mocktail/vr/headset_detection.h new file mode 100644 index 0000000..d295b14 --- /dev/null +++ b/include/mocktail/vr/headset_detection.h @@ -0,0 +1,35 @@ +#ifndef MOCKTAIL_VR_HEADSET_DETECTION_H_ +#define MOCKTAIL_VR_HEADSET_DETECTION_H_ + +#include +#include + +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_ diff --git a/include/mocktail/vr/openxr_backend.h b/include/mocktail/vr/openxr_backend.h index c3df817..2c34e5f 100644 --- a/include/mocktail/vr/openxr_backend.h +++ b/include/mocktail/vr/openxr_backend.h @@ -76,6 +76,8 @@ VrRuntimeDiscovery DiscoverVrRuntime( 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); diff --git a/include/runtime/runtime_config.h b/include/runtime/runtime_config.h index 9be1877..a38515c 100644 --- a/include/runtime/runtime_config.h +++ b/include/runtime/runtime_config.h @@ -20,6 +20,11 @@ namespace runtime { // 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, @@ -101,6 +106,8 @@ class RuntimeConfig { 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_; @@ -174,6 +181,7 @@ 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; diff --git a/src/main.cc b/src/main.cc index 5d579ab..50436fa 100644 --- a/src/main.cc +++ b/src/main.cc @@ -24,6 +24,7 @@ #include "mocktail/audio/fmod_jni_audio_bridge.h" #include "mocktail/audio/webrtc_jni_audio_bridge.h" #include "mocktail/audio/roblox_output_device_bridge.h" +#include "mocktail/vr/headset_detection.h" #include "mocktail/vr/openxr_backend.h" #include "mocktail/vr/openxr_probe.h" #include "mocktail/vr/roblox_vr_device_bridge.h" @@ -251,6 +252,11 @@ void ConfigureHostDriverEnvironment() { int main(int argc, char* argv[]) { ConfigureHostDriverEnvironment(); + // vr.mode: auto asks SteamVR for a headset from a copy of this process. + if (const char* probe = std::getenv(mocktail::vr::kHeadsetProbeEnvironment); + probe != nullptr && std::string_view(probe) == "1") { + return mocktail::vr::RunHeadsetProbeChild(); + } const auto process_started_at = std::chrono::system_clock::now(); mocktail::runtime::CommandLineParseResult command_line = mocktail::runtime::ParseCommandLine(argc, argv); @@ -409,10 +415,38 @@ int main(int argc, char* argv[]) { } return EXIT_FAILURE; } + if (command_line.options.mode == mocktail::runtime::CommandMode::kRun && + runtime_config.config.vr_mode() == mocktail::runtime::VrMode::kAuto) { + std::error_code self_error; + const std::filesystem::path self = + std::filesystem::read_symlink("/proc/self/exe", self_error); + const mocktail::vr::HeadsetDetection detection = + mocktail::vr::DetectSteamVrHeadset(self_error ? std::string() + : self.string()); + std::cerr << " [vr] vr.mode is auto: " << detection.reason + << (detection.headset ? "; starting in VR\n" + : "; starting on the desktop\n"); + if (detection.headset) { + // A detected headset is not an explicit --vr: a Roblox build without + // VR support still starts on the desktop. + if (setenv("MOCKTAIL_VR_ENABLED", "1", 1) != 0) { + std::cerr << "[FATAL] Cannot enable VR for the detected headset\n"; + return EXIT_FAILURE; + } + runtime_config = mocktail::runtime::LoadRuntimeConfig( + environment, paths.config_file()); + if (!runtime_config) { + std::cerr << "[FATAL] Cannot load " << paths.config_file() << ": " + << runtime_config.error << '\n'; + return EXIT_FAILURE; + } + } + } if (runtime_config.config.vr_enabled() && !mocktail::vr::IsOpenXrSupportCompiled()) { std::cerr << " [vr] This build does not include OpenXR support, but " - "vr.enabled is set; disable it in " << paths.config_file() + "VR is on; set vr.mode to off or auto in " + << paths.config_file() << ".\n"; return EXIT_FAILURE; } diff --git a/src/runtime/runtime_config.cc b/src/runtime/runtime_config.cc index a9c4c63..1412bbf 100644 --- a/src/runtime/runtime_config.cc +++ b/src/runtime/runtime_config.cc @@ -173,11 +173,35 @@ std::string BuildNetworkProxyUrl(const NetworkProxyConfig& proxy) { (ipv6 ? "]" : "") + ":" + std::to_string(proxy.port); } +const char* VrModeName(VrMode mode) { + switch (mode) { + case VrMode::kOn: + return "on"; + case VrMode::kAuto: + return "auto"; + case VrMode::kOff: + break; + } + return "off"; +} + RuntimeConfig RuntimeConfig::FromEnvironment(const Environment& environment) { RuntimeConfig config; config.headless_ = LegacyEnabled(environment, "MOCKTAIL_HEADLESS"); - config.vr_enabled_ = - ReadBoolean(environment, "MOCKTAIL_VR_ENABLED", kDefaultVrEnabled, &config.vr_valid_); + if (environment.Get("MOCKTAIL_VR_ENABLED").has_value()) { + config.vr_enabled_ = ReadBoolean(environment, "MOCKTAIL_VR_ENABLED", + kDefaultVrEnabled, &config.vr_valid_); + config.vr_mode_ = config.vr_enabled_ ? VrMode::kOn : VrMode::kOff; + } else if (const auto mode = environment.Get("MOCKTAIL_VR_MODE"); + mode.has_value()) { + if (*mode == "auto") { + config.vr_mode_ = VrMode::kAuto; + } else { + config.vr_enabled_ = ReadBoolean(environment, "MOCKTAIL_VR_MODE", + kDefaultVrEnabled, &config.vr_valid_); + config.vr_mode_ = config.vr_enabled_ ? VrMode::kOn : VrMode::kOff; + } + } config.roblox_library_path_ = environment.GetOr( "ROBLOX_LIB_PATH", config.roblox_library_path_.string()); config.graphics_backend_name_ = environment.GetOr( diff --git a/src/runtime/runtime_config_bootstrap.cc b/src/runtime/runtime_config_bootstrap.cc index 21726ef..5c67040 100644 --- a/src/runtime/runtime_config_bootstrap.cc +++ b/src/runtime/runtime_config_bootstrap.cc @@ -72,15 +72,14 @@ graphics: # vsync: off # vr: - # Boolean (default: false). Same as launching with --vr. - # Experimental VR for verified builds 2998 and 3092. Mocktail initializes - # the guest DebugDeviceVR eye resources (two - # distinct 500x500 eye framebuffers), the Roblox renderer draws both eyes - # every frame, and the frames are submitted to the active OpenXR runtime as - # a stereo projection layer with head pose (runtime-tracked, or scripted for - # desktop testing). Controllers use the native VR bridge. Standalone OpenXR - # test scene: mocktail-vr-probe --scene. Use --no-vr to launch normally. - # enabled: false + # 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 # 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 diff --git a/src/runtime/runtime_config_file.cc b/src/runtime/runtime_config_file.cc index f863823..4b33269 100644 --- a/src/runtime/runtime_config_file.cc +++ b/src/runtime/runtime_config_file.cc @@ -169,6 +169,7 @@ bool ValidateAndMap(const ValueMap& yaml, ValueMap* environment, "runtime.headless", "runtime.roblox_library", "vr.enabled", + "vr.mode", "vr.frame_rate_limit", "appearance.theme", "graphics.backend", @@ -320,13 +321,28 @@ bool ValidateAndMap(const ValueMap& yaml, ValueMap* environment, } (*environment)["MOCKTAIL_THEME"] = *theme; } - if (const auto enabled = value("vr.enabled"); enabled.has_value()) { + // The file only sets MOCKTAIL_VR_MODE, so --vr and --no-vr + // (MOCKTAIL_VR_ENABLED) always win over it. + const auto enabled = value("vr.enabled"); + const auto mode = value("vr.mode"); + if (enabled.has_value() && mode.has_value()) { + *error = "set either vr.mode or vr.enabled, not both"; + return false; + } + if (enabled.has_value()) { bool parsed = false; if (!ParseBoolean(*enabled, &parsed)) { *error = "vr.enabled must be true or false"; return false; } - (*environment)["MOCKTAIL_VR_ENABLED"] = parsed ? "1" : "0"; + (*environment)["MOCKTAIL_VR_MODE"] = parsed ? "on" : "off"; + } + if (mode.has_value()) { + if (*mode != "on" && *mode != "off" && *mode != "auto") { + *error = "vr.mode must be on, off, or auto"; + return false; + } + (*environment)["MOCKTAIL_VR_MODE"] = *mode; } if (const auto frame_rate = value("vr.frame_rate_limit"); frame_rate.has_value()) { @@ -803,7 +819,8 @@ RuntimeConfigLoadResult LoadRuntimeConfig( result.error = "VR frame-rate policy is invalid"; } else if (!result.config.vr_valid()) { result.error = "VR configuration is invalid: MOCKTAIL_VR_ENABLED must be " - "1/0, true/false, or on/off"; + "1/0, true/false, or on/off, and MOCKTAIL_VR_MODE on, off, " + "or auto"; } else if (!result.config.device_profile_valid()) { result.error = "device profile is invalid"; } else if (!result.config.input_capabilities().touch_enabled && diff --git a/src/vr/headset_detection.cc b/src/vr/headset_detection.cc new file mode 100644 index 0000000..0a70283 --- /dev/null +++ b/src/vr/headset_detection.cc @@ -0,0 +1,101 @@ +#include "mocktail/vr/headset_detection.h" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "mocktail/vr/openxr_backend.h" +#include "mocktail/vr/openxr_probe.h" + +extern char** environ; + +namespace mocktail::vr { + +HeadsetDetection DetectSteamVrHeadset(const std::string& self_executable, + std::chrono::milliseconds timeout, + const std::string& proc_root) { + if (!IsOpenXrSupportCompiled()) { + return {false, "this build has no OpenXR support"}; + } + const std::string manifest = RunningSteamVrManifest(proc_root); + if (manifest.empty()) { + return {false, "SteamVR is not running"}; + } + std::error_code error; + if (!std::filesystem::is_regular_file(manifest, error)) { + return {false, "SteamVR's OpenXR runtime is missing at " + manifest}; + } + + // The child inherits this environment with the runtime and probe mode set. + const std::string runtime = "XR_RUNTIME_JSON=" + manifest; + const std::string probe = std::string(kHeadsetProbeEnvironment) + "=1"; + std::vector environment; + for (char** entry = environ; entry != nullptr && *entry != nullptr; ++entry) { + if (std::strncmp(*entry, "XR_RUNTIME_JSON=", 16) != 0 && + std::strncmp(*entry, probe.c_str(), probe.size() - 1) != 0) { + environment.push_back(*entry); + } + } + environment.push_back(const_cast(runtime.c_str())); + environment.push_back(const_cast(probe.c_str())); + environment.push_back(nullptr); + std::vector arguments = {const_cast(self_executable.c_str()), + nullptr}; + + posix_spawn_file_actions_t actions; + posix_spawn_file_actions_init(&actions); + posix_spawn_file_actions_addopen(&actions, STDOUT_FILENO, "/dev/null", + O_WRONLY, 0); + posix_spawn_file_actions_addopen(&actions, STDERR_FILENO, "/dev/null", + O_WRONLY, 0); + pid_t child = -1; + const int spawned = + posix_spawn(&child, self_executable.c_str(), &actions, nullptr, + arguments.data(), environment.data()); + posix_spawn_file_actions_destroy(&actions); + if (spawned != 0) { + return {false, std::string("cannot start the headset check: ") + + std::strerror(spawned)}; + } + + const auto deadline = std::chrono::steady_clock::now() + timeout; + int status = 0; + while (true) { + const pid_t done = waitpid(child, &status, WNOHANG); + if (done == child) break; + if (done < 0) { + return {false, "the headset check was lost"}; + } + if (std::chrono::steady_clock::now() >= deadline) { + kill(child, SIGKILL); + (void)waitpid(child, &status, 0); + return {false, "SteamVR did not answer the headset check in time"}; + } + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + } + if (!WIFEXITED(status)) { + return {false, "the headset check crashed"}; + } + const int code = WEXITSTATUS(status); + if (code == ProbeExitCode(ProbeStatus::kReady)) { + return {true, "SteamVR reports a connected headset"}; + } + if (code == ProbeExitCode(ProbeStatus::kHeadsetUnavailable)) { + return {false, "SteamVR is running without a connected headset"}; + } + if (code == ProbeExitCode(ProbeStatus::kRuntimeUnavailable)) { + return {false, "SteamVR's OpenXR runtime is unavailable"}; + } + return {false, "SteamVR's OpenXR runtime failed the headset check"}; +} + +int RunHeadsetProbeChild() { return ProbeExitCode(ProbeOpenXr().status); } + +} // namespace mocktail::vr diff --git a/src/vr/openxr_backend_mode.cc b/src/vr/openxr_backend_mode.cc index 007a831..1175b25 100644 --- a/src/vr/openxr_backend_mode.cc +++ b/src/vr/openxr_backend_mode.cc @@ -82,6 +82,55 @@ std::vector VrRuntimeManifestCandidates( return candidates; } +namespace { +// Restrict process discovery to this user. In particular, never select a +// root server for a desktop client or another user's SteamVR installation. +std::vector> +UserVrServers(const std::string &proc_root) { + std::vector> servers; + std::error_code error; + std::filesystem::directory_iterator processes(proc_root, error); + for (; !error && processes != std::filesystem::directory_iterator(); + processes.increment(error)) { + const auto name = processes->path().filename().string(); + if (name.empty() || !std::all_of(name.begin(), name.end(), [](char c) { + return c >= '0' && c <= '9'; + })) + continue; + struct stat metadata{}; + if (stat(processes->path().c_str(), &metadata) != 0 || + metadata.st_uid != getuid()) + continue; + std::ifstream comm_file(processes->path() / "comm"); + std::string comm; + std::getline(comm_file, comm); + if (comm != "vrserver" && comm != "wivrn-server") + continue; + std::error_code exe_error; + const auto executable = + std::filesystem::read_symlink(processes->path() / "exe", exe_error); + if (exe_error || !executable.is_absolute() || executable.filename() != comm) + continue; + servers.emplace_back(comm, executable); + } + return servers; +} + +// vrserver lives in /bin/linux64/. +std::string SteamVrManifestForServer(const std::filesystem::path &vrserver) { + return (vrserver.parent_path().parent_path().parent_path() / + "steamxr_linux64.json") + .string(); +} +} // namespace + +std::string RunningSteamVrManifest(const std::string &proc_root) { + for (const auto &[comm, executable] : UserVrServers(proc_root)) + if (comm == "vrserver") + return SteamVrManifestForServer(executable); + return {}; +} + VrRuntimeDiscovery DiscoverVrRuntime(const std::string &preference, const std::string &home, const std::string &config_home, @@ -118,37 +167,10 @@ VrRuntimeDiscovery DiscoverVrRuntime(const std::string &preference, } } } - // Restrict process discovery to this user. In particular, never select a - // root server for a desktop client or another user's SteamVR installation. - std::error_code error; - std::filesystem::directory_iterator processes(proc_root, error); - for (; !error && processes != std::filesystem::directory_iterator(); - processes.increment(error)) { - const auto name = processes->path().filename().string(); - if (name.empty() || !std::all_of(name.begin(), name.end(), [](char c) { - return c >= '0' && c <= '9'; - })) - continue; - struct stat metadata{}; - if (stat(processes->path().c_str(), &metadata) != 0 || - metadata.st_uid != getuid()) - continue; - std::ifstream comm_file(processes->path() / "comm"); - std::string comm; - std::getline(comm_file, comm); - if (comm != "vrserver" && comm != "wivrn-server") - continue; - std::error_code exe_error; - const auto executable = - std::filesystem::read_symlink(processes->path() / "exe", exe_error); - if (exe_error || !executable.is_absolute() || executable.filename() != comm) - continue; + for (const auto &[comm, executable] : UserVrServers(proc_root)) { if (comm == "vrserver") { steam_running = true; - steam.insert(steam.begin(), - (executable.parent_path().parent_path().parent_path() / - "steamxr_linux64.json") - .string()); + steam.insert(steam.begin(), SteamVrManifestForServer(executable)); } else { // Native developer builds keep this manifest at their build root. auto directory = executable.parent_path(); diff --git a/tests/runtime_config_file_test.cc b/tests/runtime_config_file_test.cc index 26e576f..e00e3fd 100644 --- a/tests/runtime_config_file_test.cc +++ b/tests/runtime_config_file_test.cc @@ -134,6 +134,48 @@ TEST(RuntimeConfigFileTest, EXPECT_NE(invalid.error.find("VR configuration"), std::string::npos); } +TEST(RuntimeConfigFileTest, VrModeSelectsOnOffOrAuto) { + TemporaryDirectory temporary; + const auto load = [&](const std::string& vr, MapEnvironment environment = + MapEnvironment()) { + return LoadRuntimeConfig(environment, + temporary.Write("version: 1\nvr:\n" + vr)); + }; + const auto on = load(" mode: on\n"); + ASSERT_TRUE(on) << on.error; + EXPECT_EQ(on.config.vr_mode(), VrMode::kOn); + EXPECT_TRUE(on.config.vr_enabled()); + const auto off = load(" mode: off\n"); + ASSERT_TRUE(off) << off.error; + EXPECT_EQ(off.config.vr_mode(), VrMode::kOff); + EXPECT_FALSE(off.config.vr_enabled()); + // auto stays on the desktop until main() finds a headset. + const auto automatic = load(" mode: auto\n"); + ASSERT_TRUE(automatic) << automatic.error; + EXPECT_EQ(automatic.config.vr_mode(), VrMode::kAuto); + EXPECT_FALSE(automatic.config.vr_enabled()); + // The older key still means on/off. + EXPECT_EQ(load(" enabled: true\n").config.vr_mode(), VrMode::kOn); + EXPECT_EQ(load(" enabled: false\n").config.vr_mode(), VrMode::kOff); + // --vr / --no-vr (and a detected headset) win over the file. + const auto forced = load(" mode: auto\n", + MapEnvironment({{"MOCKTAIL_VR_ENABLED", "1"}})); + ASSERT_TRUE(forced) << forced.error; + EXPECT_EQ(forced.config.vr_mode(), VrMode::kOn); + EXPECT_TRUE(forced.config.vr_enabled()); + const auto desktop = load(" mode: on\n", + MapEnvironment({{"MOCKTAIL_VR_ENABLED", "0"}})); + ASSERT_TRUE(desktop) << desktop.error; + EXPECT_FALSE(desktop.config.vr_enabled()); + + const auto both = load(" mode: auto\n enabled: true\n"); + EXPECT_FALSE(both); + EXPECT_NE(both.error.find("vr.mode"), std::string::npos); + const auto invalid = load(" mode: sometimes\n"); + EXPECT_FALSE(invalid); + EXPECT_NE(invalid.error.find("vr.mode"), std::string::npos); +} + TEST(RuntimeConfigFileTest, VrFrameRateLimitIsSeparateFromDesktopLimit) { TemporaryDirectory temporary; const auto defaults = LoadRuntimeConfig( diff --git a/tests/runtime_config_test.cc b/tests/runtime_config_test.cc index 74ab0ab..880077e 100644 --- a/tests/runtime_config_test.cc +++ b/tests/runtime_config_test.cc @@ -106,6 +106,26 @@ TEST(RuntimeConfigTest, RejectsMalformedVrEnvironmentInsteadOfEnablingIt) { EXPECT_EQ(config.vr_enabled(), kDefaultVrEnabled); } +TEST(RuntimeConfigTest, ReadsVrModeAndLetsVrEnabledWin) { + EXPECT_EQ(RuntimeConfig::FromEnvironment(MapEnvironment()).vr_mode(), + kDefaultVrEnabled ? VrMode::kOn : VrMode::kOff); + const auto automatic = RuntimeConfig::FromEnvironment( + MapEnvironment({{"MOCKTAIL_VR_MODE", "auto"}})); + EXPECT_TRUE(automatic.vr_valid()); + EXPECT_EQ(automatic.vr_mode(), VrMode::kAuto); + EXPECT_FALSE(automatic.vr_enabled()); + EXPECT_TRUE(RuntimeConfig::FromEnvironment( + MapEnvironment({{"MOCKTAIL_VR_MODE", "on"}})) + .vr_enabled()); + const auto overridden = RuntimeConfig::FromEnvironment(MapEnvironment( + {{"MOCKTAIL_VR_MODE", "auto"}, {"MOCKTAIL_VR_ENABLED", "0"}})); + EXPECT_EQ(overridden.vr_mode(), VrMode::kOff); + EXPECT_FALSE(RuntimeConfig::FromEnvironment( + MapEnvironment({{"MOCKTAIL_VR_MODE", "sometimes"}})) + .vr_valid()); + EXPECT_STREQ(VrModeName(VrMode::kAuto), "auto"); +} + TEST(RuntimeConfigTest, ReadsTypedRuntimeValues) { const MapEnvironment environment({ {"MOCKTAIL_HEADLESS", "1"}, diff --git a/tests/vr/runtime_detection_test.cc b/tests/vr/runtime_detection_test.cc index b5eca65..ec37e03 100644 --- a/tests/vr/runtime_detection_test.cc +++ b/tests/vr/runtime_detection_test.cc @@ -1,4 +1,6 @@ +#include "mocktail/vr/headset_detection.h" #include "mocktail/vr/openxr_backend.h" +#include "mocktail/vr/openxr_probe.h" #include #include #include @@ -137,5 +139,69 @@ TEST_F(VrRuntimeDetectionTest, File(root / "config/openvr/openvrpaths.vrpath", "invalid json"); EXPECT_NE(Selected("alvr"), (install / "steamxr_linux64.json").string()); } +TEST_F(VrRuntimeDetectionTest, RunningSteamVrManifestNeedsThisUsersServer) { + EXPECT_EQ(RunningSteamVrManifest((root / "proc").string()), ""); + const auto manifest = Steam(); + EXPECT_EQ(RunningSteamVrManifest((root / "proc").string()), manifest); +} + +class VrHeadsetDetectionTest : public VrRuntimeDetectionTest { +protected: + // Stands in for the probe child: a script that sees the same environment. + std::string Probe(const std::string &body) { + const auto path = root / "probe.sh"; + File(path, "#!/bin/sh\n" + body + "\n"); + std::filesystem::permissions(path, std::filesystem::perms::owner_all); + return path.string(); + } + HeadsetDetection Detect(const std::string &probe, + std::chrono::milliseconds timeout = + std::chrono::seconds(5)) { + return DetectSteamVrHeadset(probe, timeout, (root / "proc").string()); + } +}; +TEST_F(VrHeadsetDetectionTest, NoSteamVrMeansDesktopWithoutStartingIt) { + if (!IsOpenXrSupportCompiled()) + GTEST_SKIP() << "built without OpenXR"; + const auto marker = root / "started"; + const auto result = Detect(Probe("touch " + marker.string() + "; exit 0")); + EXPECT_FALSE(result.headset); + EXPECT_NE(result.reason.find("not running"), std::string::npos); + EXPECT_FALSE(std::filesystem::exists(marker)); +} +TEST_F(VrHeadsetDetectionTest, AsksSteamVrsRuntimeInAProbeChild) { + if (!IsOpenXrSupportCompiled()) + GTEST_SKIP() << "built without OpenXR"; + const auto manifest = Steam(); + const auto result = Detect(Probe( + "[ \"$XR_RUNTIME_JSON\" = \"" + manifest + "\" ] && [ \"$" + + kHeadsetProbeEnvironment + "\" = 1 ] && exit 0; exit 9")); + EXPECT_TRUE(result.headset) << result.reason; +} +TEST_F(VrHeadsetDetectionTest, MapsProbeOutcomesToDesktop) { + if (!IsOpenXrSupportCompiled()) + GTEST_SKIP() << "built without OpenXR"; + Steam(); + const auto missing = + Detect(Probe("exit " + std::to_string(ProbeExitCode( + ProbeStatus::kHeadsetUnavailable)))); + EXPECT_FALSE(missing.headset); + EXPECT_NE(missing.reason.find("without a connected headset"), + std::string::npos); + EXPECT_FALSE(Detect(Probe("exit 1")).headset); + EXPECT_FALSE(Detect(Probe("kill -9 $$")).headset); + EXPECT_FALSE(Detect(root / "no-such-probe").headset); +} +TEST_F(VrHeadsetDetectionTest, GivesUpOnASteamVrThatDoesNotAnswer) { + if (!IsOpenXrSupportCompiled()) + GTEST_SKIP() << "built without OpenXR"; + Steam(); + const auto started = std::chrono::steady_clock::now(); + const auto result = + Detect(Probe("exec sleep 30"), std::chrono::milliseconds(200)); + EXPECT_FALSE(result.headset); + EXPECT_NE(result.reason.find("in time"), std::string::npos); + EXPECT_LT(std::chrono::steady_clock::now() - started, std::chrono::seconds(5)); +} } // namespace } // namespace mocktail::vr