Files
mitch030504--Wiicompiled_VR…/runtime/src/settings_overlay.cpp
T
Claude 55d921a118 Add --update and --check to the install script, and an Updates tab in the game
--update reuses the options an install was made with (saved to install.conf in the work dir) and
builds nothing when the Frame already has the newest release and Retro Rewind pack; --check only
reports. The installed release is stamped as .release-tag beside the game on the Frame.

An install built on the Frame itself (--frame local) also gets a systemd user service the game's
new Updates tab starts: the update runs in the background at low priority, writes each step and
the build percentage to a status file the tab shows, and reopens the game at the end if it was
closed. Progress does not go through Steam notifications: on the Frame, Steam receives them from
steam_notif_daemon but does not display them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UyuWx5hNrMpf9R78FZFcbA
2026-10-05 16:27:15 +00:00

2825 lines
136 KiB
C++

#include "settings_overlay.h"
#include "audio_backend.h"
#include "aurora_events.h"
#include "controller_button_names.h"
#include "controller_mapping_wizard.h"
#include "gx_native_wheel.h"
#include "input_bindings.h"
#include "game_graphics_options.h"
#include "log_export.h"
#include "physical_wheel.h"
#include "music_attenuation.h"
#include "runtime_config.h"
#include "runtime_log.h"
#include "update_check.h"
#include "vr/camera_toggle.h"
#include "vr/mkw_vr_culling.h"
#include "vr/mkw_vr_first_person.h"
#include "vr/mkw_vr_policy.h"
#include "vr/openxr_diagnostics.h"
#include "vr/openxr_driving.h"
#include "vr/openxr_integration.h"
#include "vr/openxr_settings_panel.h"
#include "vr/openxr_wii_remote.h"
#include "wii_remote_input.h"
#include <aurora/imgui.h>
#include <imgui.h>
#include <SDL3/SDL_dialog.h>
#include <SDL3/SDL_error.h>
#include <SDL3/SDL_events.h>
#include <SDL3/SDL_gamepad.h>
#include <SDL3/SDL_keyboard.h>
#include <SDL3/SDL_mouse.h>
#include <SDL3/SDL_scancode.h>
#include <SDL3/SDL_timer.h>
#include <array>
#include <algorithm>
#include <atomic>
#include <unordered_map>
#include <cctype>
#include <cfloat>
#include <charconv>
#include <chrono>
#include <cmath>
#include <cstdint>
#include <exception>
#include <limits>
#include <iostream>
#include <mutex>
#include <sstream>
#include <string>
#include <string_view>
#include <utility>
#if defined(_WIN32)
#define WIN32_LEAN_AND_MEAN
#include <Windows.h>
#include <shellapi.h>
#else
#include <unistd.h>
#endif
#include <dolphin/pad.h>
extern "C" void PAD_HLE_SetRumbleEnabled(bool enabled);
#include <dolphin/vi.h>
#include <aurora/aurora.h>
#include <aurora/gfx.h>
extern "C" int g_gxFrameCount;
// Defined in runtime/src/hle/audio/ax_mix.cpp. That header is private to the HLE
// directory and is not on this target's include path.
namespace AxDspHle {
void SetMixWorkerEnabled(bool enabled);
}
namespace settings_overlay {
namespace {
const char* GraphicsApiDisplayName() {
switch (aurora_get_backend()) {
case BACKEND_D3D11: return "Direct3D 11";
case BACKEND_D3D12: return "Direct3D 12";
case BACKEND_METAL: return "Metal";
case BACKEND_VULKAN: return "Vulkan";
case BACKEND_OPENGL: return "OpenGL";
case BACKEND_OPENGLES: return "OpenGL ES";
case BACKEND_WEBGPU: return "WebGPU";
case BACKEND_NULL: return "Null";
case BACKEND_AUTO: return "Automatic";
}
return "Unknown";
}
// Fixed widths in the menus are written for the desktop bar's 13 px font. The
// headset's settings panel draws the same menus with a larger one.
float Scaled(float pixels) {
return pixels * ImGui::GetFontSize() / 13.0f;
}
bool g_topBarVisible = false;
bool g_exitPromptOpen = false;
bool g_rumbleEnabled = RuntimeConfigFile::RumbleEnabled(true);
int g_controllerPort = 0;
float g_resolutionScale = RuntimeConfigFile::ResolutionMultiplier(1.0f);
int g_audioVolumePercent = static_cast<int>(std::lround(RuntimeConfigFile::AudioVolume(1.0f) * 100.0f));
int g_musicVolumePercent = static_cast<int>(std::lround(RuntimeConfigFile::MusicVolume(1.0f) * 100.0f));
int g_soundEffectsVolumePercent =
static_cast<int>(std::lround(RuntimeConfigFile::SoundEffectsVolume(1.0f) * 100.0f));
int g_uiVolumePercent = static_cast<int>(std::lround(RuntimeConfigFile::UiVolume(1.0f) * 100.0f));
int g_voicesVolumePercent = static_cast<int>(std::lround(RuntimeConfigFile::VoicesVolume(1.0f) * 100.0f));
bool g_audioMuted = RuntimeConfigFile::AudioMuted(false);
int32_t g_muteHotkey = RuntimeConfigFile::MuteHotkey(SDL_SCANCODE_BACKSLASH);
bool g_audioMixWorker = RuntimeConfigFile::AudioMixWorkerEnabled(true);
bool g_attenuateMusicWhenMediaPlays = RuntimeConfigFile::AttenuateMusicWhenMediaPlays(false);
int g_frameInterpolationMode = [] {
switch (RuntimeConfigFile::FrameInterpolationFps(0)) {
case 120:
return 1;
case 180:
return 2;
default:
return 0;
}
}();
int g_displayMode = [] {
const std::string mode = RuntimeConfigFile::DisplayMode("windowed");
if (mode == "borderless") {
return static_cast<int>(AURORA_DISPLAY_MODE_BORDERLESS);
}
if (mode == "exclusive") {
return static_cast<int>(AURORA_DISPLAY_MODE_EXCLUSIVE);
}
return static_cast<int>(AURORA_DISPLAY_MODE_WINDOWED);
}();
bool g_skipUnreadyPipelines = RuntimeConfigFile::SkipUnreadyPipelines(true);
bool g_disableCopyFilter = RuntimeConfigFile::DisableCopyFilter(true);
bool g_showFps = RuntimeConfigFile::ShowFps();
// The same default the VR path itself takes (kVrEnabledDefault), so the F10 switch
// shows what an unconfigured installation actually starts in.
bool g_vrEnabled = RuntimeConfigFile::VrEnabled(true);
bool g_vrHudVirtualScreen = RuntimeConfigFile::VrHudVirtualScreen(true);
// Race view: Immersive, Immersive window or Flat screen (RuntimeConfigFile::VrRaceView), and
// Flat Screen mode as the flag the race view rows below are disabled by.
int g_vrRaceView = static_cast<int>(RuntimeConfigFile::GetVrRaceView());
bool g_vrFlatScreen = g_vrRaceView == static_cast<int>(RuntimeConfigFile::VrRaceView::FlatScreen);
constexpr std::array<const char*, 3> kVrRaceViewLabels{"Immersive", "Immersive window", "Flat screen"};
#if defined(MKW_VR_STANDALONE)
#if defined(__ANDROID__) && !defined(MKW_HEADSET_STEAM_FRAME)
bool g_vrPassthrough = RuntimeConfigFile::VrPassthrough();
#endif
bool g_vrHandTracking = RuntimeConfigFile::VrHandTracking();
// Menu labels for the foveation levels, index-matched to RuntimeConfigFile::kVrFoveationLevels and to
// aurora_set_stereo_foveation.
constexpr std::array<const char*, 4> kVrFoveationLabels{"Off", "Low", "Medium", "High"};
static_assert(kVrFoveationLabels.size() == RuntimeConfigFile::kVrFoveationLevels.size());
int g_vrFoveation = static_cast<int>(RuntimeConfigFile::VrFoveationLevelIndex(RuntimeConfigFile::VrFoveation()));
bool g_vrEyeTrackedFoveation = RuntimeConfigFile::VrEyeTrackedFoveation();
#endif
bool g_vrRepeatFrames = RuntimeConfigFile::VrRepeatFrames();
bool g_vrAdaptiveResolution = RuntimeConfigFile::VrAdaptiveResolution();
bool g_vrFirstPerson = RuntimeConfigFile::VrFirstPerson(false);
bool g_vrFirstPersonToggleClick = RuntimeConfigFile::VrFirstPersonToggleClick();
// Set from any thread by the right-thumbstick click, applied on the game thread.
std::atomic<bool> g_firstPersonToggleRequested{false};
// Per physical gamepad; the VR controllers keep theirs on the XR side.
std::unordered_map<SDL_JoystickID, mkw::vr::ClickToggle> g_gamepadFirstPersonClicks;
float g_vrFirstPersonUnitsPerMeter = RuntimeConfigFile::VrFirstPersonUnitsPerMeter();
// 0 = cockpit, 1 = custom, matching kVrFirstPersonSeatNames.
constexpr std::array<const char*, 2> kVrFirstPersonSeatNames{"cockpit", "custom"};
int g_vrFirstPersonSeat = RuntimeConfigFile::VrFirstPersonSeat() == "custom" ? 1 : 0;
float g_vrCockpitUnitsPerMeter = RuntimeConfigFile::VrCockpitUnitsPerMeter();
bool g_vrSteeringWheel = RuntimeConfigFile::VrSteeringWheel();
bool g_vrNativeSteeringWheel = RuntimeConfigFile::VrNativeSteeringWheel();
bool g_vrPlaceholderSteeringWheel = RuntimeConfigFile::VrPlaceholderSteeringWheel();
bool g_vrObjectCulling = RuntimeConfigFile::VrObjectCulling();
bool g_vrHandSteering = RuntimeConfigFile::VrHandSteering();
constexpr std::array<const char*, 3> kVrCockpitItemHands{"Left", "Right", "Off"};
bool g_vrCockpitItemThrow = RuntimeConfigFile::VrCockpitItemThrow();
int g_vrCockpitItemHand = RuntimeConfigFile::VrCockpitItemHand() == "right" ? 1 :
RuntimeConfigFile::VrCockpitItemHand() == "off" ? 2 : 0;
mkw::vr::WheelTuning g_vrWheelTuning = RuntimeConfigFile::VrWheelTuning();
float g_vrFirstPersonHeadUp = RuntimeConfigFile::VrFirstPersonHeadUpMeters();
float g_vrFirstPersonHeadForward = RuntimeConfigFile::VrFirstPersonHeadForwardMeters();
float g_vrFirstPersonHeadRight = RuntimeConfigFile::VrFirstPersonHeadRightMeters();
bool g_vrFirstPersonHideDriver = RuntimeConfigFile::VrFirstPersonHideDriver();
constexpr std::array<uint32_t, 5> kVrInterpolationFps{0, 1, 72, 90, 120};
constexpr std::array<const char*, 5> kVrInterpolationLabels{"Off", "Auto", "72", "90", "120"};
int g_vrFrameInterpolationMode = [] {
const auto value = RuntimeConfigFile::VrFrameInterpolationFps();
return static_cast<int>(std::find(kVrInterpolationFps.begin(), kVrInterpolationFps.end(), value) -
kVrInterpolationFps.begin());
}();
// [vr] refresh_rate choices; a value set in Config.toml outside them is shown as it is.
constexpr std::array<uint32_t, 5> kVrRefreshRates{0, 72, 90, 120, 144};
uint32_t g_vrRefreshRate = RuntimeConfigFile::VrRefreshRate();
int g_vrFirstPersonHiddenModel = RuntimeConfigFile::VrFirstPersonHiddenModel();
bool g_openxrDiagnosticsLogging = RuntimeConfigFile::DiagnosticsOpenXRLogging(false);
bool g_firstPersonDiagnosticsLogging = RuntimeConfigFile::DiagnosticsFirstPersonLogging(false);
// Config spellings and menu labels for the desktop mirror, index-matched to
// AuroraStereoMirrorView so the combo selection converts to either directly.
constexpr std::array<const char*, 5> kVrMirrorViewNames{"normal", "both", "left", "right", "none"};
constexpr std::array<const char*, 5> kVrMirrorViewLabels{"Normal", "Both eyes", "Left eye", "Right eye", "None"};
static_assert(kVrMirrorViewNames.size() == kVrMirrorViewLabels.size());
static_assert(static_cast<int>(AURORA_STEREO_MIRROR_NORMAL) == 0);
static_assert(static_cast<int>(AURORA_STEREO_MIRROR_BOTH_EYES) == 1);
static_assert(static_cast<int>(AURORA_STEREO_MIRROR_LEFT_EYE) == 2);
static_assert(static_cast<int>(AURORA_STEREO_MIRROR_RIGHT_EYE) == 3);
static_assert(static_cast<int>(AURORA_STEREO_MIRROR_NONE) == 4);
int g_vrMirrorView = [] {
const std::string mode = RuntimeConfigFile::VrMirrorView();
for (size_t i = 0; i < kVrMirrorViewNames.size(); ++i) {
if (mode == kVrMirrorViewNames[i]) {
return static_cast<int>(i);
}
}
return 0;
}();
// Config spellings and menu labels for the VR controllers, index-matched to
// mkw::vr::OpenXRControllerMode.
constexpr std::array<const char*, 3> kVrControllerModeNames{"wii_remote", "gamepad", "none"};
constexpr std::array<const char*, 3> kVrControllerModeLabels{"Wii Remote + Nunchuk", "Gamepad", "None"};
static_assert(static_cast<int>(mkw::vr::OpenXRControllerMode::WiiRemote) == 0);
static_assert(static_cast<int>(mkw::vr::OpenXRControllerMode::Gamepad) == 1);
static_assert(static_cast<int>(mkw::vr::OpenXRControllerMode::None) == 2);
int g_vrControllerMode = [] {
const std::string mode = RuntimeConfigFile::VrControllerMode();
for (size_t i = 0; i < kVrControllerModeNames.size(); ++i) {
if (mode == kVrControllerModeNames[i]) {
return static_cast<int>(i);
}
}
return 0;
}();
constexpr std::array<const char*, 3> kVrFirstPersonRotationNames{"yaw", "yaw_pitch", "full"};
int VrFirstPersonRotationIndex(std::string_view mode) {
for (size_t i = 0; i < kVrFirstPersonRotationNames.size(); ++i) {
if (mode == kVrFirstPersonRotationNames[i]) {
return static_cast<int>(i);
}
}
return 0;
}
int g_vrFirstPersonRotation = VrFirstPersonRotationIndex(RuntimeConfigFile::VrFirstPersonRotation());
// SDL_SCANCODE_UNKNOWN means unbound, which is also what an unrecognised
// name in the config file resolves to rather than silently picking a key.
SDL_Scancode g_vrRecenterScancode = [] {
const std::string name = RuntimeConfigFile::VrRecenterKey();
return name.empty() ? SDL_SCANCODE_UNKNOWN
: SDL_GetScancodeFromName(name.c_str());
}();
bool g_vrRecenterRebinding = false;
float g_vrLeanBackDegrees = RuntimeConfigFile::VrLeanBackDegrees();
// [vr] render_scale as the slider shows it, in percent of the headset's recommended size.
int g_vrRenderScalePercent = static_cast<int>(std::lround(RuntimeConfigFile::VrRenderScale() * 100.0f));
uint32_t g_disabledPostProcessingPaths = RuntimeConfigFile::DisabledPostProcessingPaths();
std::array<int32_t, PAD_MAX_CONTROLLERS> g_configuredControllerIndices = [] {
std::array<int32_t, PAD_MAX_CONTROLLERS> indices{};
indices.fill(std::numeric_limits<int32_t>::min());
return indices;
}();
using ControllerNames::kNativeButtons;
using ControllerNames::NativeButtonItem;
constexpr const auto& kControllerButtons = ControllerNames::kGameCubeButtons;
// Classic Controller Pro layout, indexed like kControllerButtons: the SNES-style
// diamond (A right, B bottom, X top, Y left) with digital bumpers driving the GC
// triggers and Z on Back/Select (the same home the NSO GC default gives it).
constexpr std::array<const char*, PAD_BUTTON_COUNT> kClassicProPreset = {
"east", // A
"south", // B
"north", // X
"west", // Y
"start", // Start
"back", // Z
"left_shoulder", // L
"right_shoulder", // R
"dpad_up", "dpad_down", "dpad_left", "dpad_right",
};
// PlayStation layout: bumpers drive the GC triggers, Z moves to Create/Share.
constexpr std::array<const char*, PAD_BUTTON_COUNT> kPlayStationPreset = {
"south", "east", "west", "north", "start", "back",
"left_shoulder", "right_shoulder",
"dpad_up", "dpad_down", "dpad_left", "dpad_right",
};
struct ResolutionItem {
const char* label;
float scale;
};
using Clock = std::chrono::steady_clock;
constexpr auto kCursorAutoHideDelay = std::chrono::seconds(5);
Clock::time_point g_lastMouseActivity{Clock::now()};
bool g_cursorHidden = false;
constexpr std::array<std::string_view, 3> kDisplayModeConfigNames = {
"windowed", "borderless", "exclusive",
};
uint64_t g_presentedFrame = 0;
std::atomic_bool g_strapInputAccepted = false;
std::atomic_uint64_t g_startupDismissFrame = UINT64_MAX;
constexpr uint64_t kStrapTransitionCoverFrames = 60;
constexpr std::array<ResolutionItem, 8> kResolutions = {{
{"Auto (window size)", 0.0f}, {"Native (1x)", 1.0f}, {"1.5x", 1.5f}, {"2x", 2.0f},
{"3x", 3.0f}, {"4x", 4.0f}, {"6x", 6.0f}, {"8x", 8.0f},
}};
constexpr std::array<uint32_t, 3> kFrameInterpolationTargetFps{0, 120, 180};
bool IsHighResolutionScale(float scale) {
return std::fabs(scale - 6.0f) < 0.001f || std::fabs(scale - 8.0f) < 0.001f;
}
bool IsHighFrameRateMode() {
return kFrameInterpolationTargetFps[static_cast<size_t>(g_frameInterpolationMode)] > 60;
}
void SetResolutionScale(float scale) {
g_resolutionScale = scale;
VISetFrameBufferScale(scale);
RuntimeConfigFile::SetResolutionMultiplier(scale);
}
void LimitResolutionForFrameRate() {
if (IsHighFrameRateMode() && IsHighResolutionScale(g_resolutionScale)) {
SetResolutionScale(4.0f);
}
}
using ControllerNames::FindNativeButton;
uint32_t ConfiguredNativeButton(const NativeButtonItem& item, const std::string& token) {
if (!PADIsAxisButton(item.nativeButton)) return item.nativeButton;
const size_t separator = token.find('@');
if (separator == std::string::npos) return item.nativeButton;
uint32_t threshold = 0;
const char* end = token.data() + token.size();
const auto parsed = std::from_chars(token.data() + separator + 1, end, threshold);
if (parsed.ec != std::errc{} || parsed.ptr != end || threshold < 1 || threshold > 100)
return item.nativeButton;
return PADAxisButtonIdentity(item.nativeButton) | (threshold << 8);
}
struct ControllerBindingPair {
std::string primary;
std::string secondary;
};
// Config values hold up to two comma-separated button names ("dpad_up" or
// "dpad_up,left_shoulder"); pressing either one counts as the GC button.
ControllerBindingPair SplitControllerBinding(const std::string& value) {
const size_t comma = value.find(',');
if (comma == std::string::npos) {
return {ControllerNames::TrimToken(value), {}};
}
return {ControllerNames::TrimToken(value.substr(0, comma)), ControllerNames::TrimToken(value.substr(comma + 1))};
}
using ControllerNames::NativeButtonForValue;
std::string NativeBindingConfig(uint32_t binding) {
std::string value = NativeButtonForValue(binding).configName;
if (PADIsAxisButton(binding)) value += '@' + std::to_string(PADAxisButtonThreshold(binding));
return value;
}
void SetTopBarVisible(bool visible) {
if (g_topBarVisible == visible) {
return;
}
g_topBarVisible = visible;
}
void ApplyConfiguredMappings() {
for (uint32_t port = 0; port < PAD_MAX_CONTROLLERS; ++port) {
const int32_t controllerIndex = PADGetIndexForPort(port);
if (controllerIndex == g_configuredControllerIndices[port]) {
continue;
}
g_configuredControllerIndices[port] = controllerIndex;
if (controllerIndex < 0) {
continue;
}
// The [controller] bindings are positional and shared by every port, so
// they describe whatever pad the user set them up with (usually an Xbox
// layout: a = south). A Wii U Pro Controller has a fixed, known layout
// (A on the east position) that aurora already maps by name; applying
// the shared bindings on top swaps A/B and X/Y. (Wii Remotes with any
// extension never reach the PAD layer: the game reads them through KPAD.)
if (WiiRemoteInput::KindForPort(port) == WiiRemoteInput::Kind::WiiUPro) {
continue;
}
uint32_t count = 0;
if (PADGetButtonMappings(port, &count) == nullptr || count != PAD_BUTTON_COUNT) {
continue;
}
for (size_t i = 0; i < kControllerButtons.size(); ++i) {
const auto& configured = RuntimeConfigFile::ControllerButton(i);
if (!configured) {
continue;
}
const ControllerBindingPair binding = SplitControllerBinding(*configured);
if (const NativeButtonItem* native = FindNativeButton(binding.primary)) {
PADSetButtonMapping(port, PADButtonMapping{ConfiguredNativeButton(*native, binding.primary), kControllerButtons[i].padButton});
} else {
RT_LOG(RT_TAG_CONFIG) << "Unknown controller." << kControllerButtons[i].configKey
<< " button '" << binding.primary << "'" << std::endl;
}
uint32_t altNative = PAD_NATIVE_BUTTON_INVALID;
if (!binding.secondary.empty()) {
if (const NativeButtonItem* native = FindNativeButton(binding.secondary)) {
altNative = ConfiguredNativeButton(*native, binding.secondary);
} else {
RT_LOG(RT_TAG_CONFIG) << "Unknown controller." << kControllerButtons[i].configKey
<< " secondary button '" << binding.secondary << "'" << std::endl;
}
}
PADSetAltButtonMapping(port, PADButtonMapping{altNative, kControllerButtons[i].padButton});
}
}
}
bool g_wiiRemotesEnabled = RuntimeConfigFile::WiiRemotesEnabled(true);
bool g_wiiContinuousScan = RuntimeConfigFile::WiiContinuousScanEnabled(false);
// Accelerometer readout and zero-point calibration for a bare remote / remote + Nunchuk.
void DrawWiiRemoteAccelerometer(uint32_t port) {
ImGui::SeparatorText("Accelerometer");
float sdlG[3] = {};
float kpad[3] = {};
if (WiiRemoteInput::ReadAccelDebug(port, sdlG, kpad)) {
ImGui::Text("KPAD acc: x %+.2f y %+.2f z %+.2f g", kpad[0], kpad[1], kpad[2]);
ImGui::TextDisabled("Flat, buttons up: (0, -1, 0). Sideways as a wheel: (1, 0, 0); z follows the turn.");
} else {
ImGui::TextDisabled("No accelerometer data yet.");
}
// SDL's read of the remote's calibration block often times out over Bluetooth
// and it falls back to a nominal zero point, leaving a small per-axis bias;
// measured here with the remote at rest.
if (WiiRemoteInput::IsAccelCalibrating()) {
ImGui::ProgressBar(WiiRemoteInput::AccelCalibrationProgress(), ImVec2(Scaled(220.0f), 0.0f), "Hold still...");
} else if (ImGui::Button("Calibrate (remote lying flat, buttons up)")) {
WiiRemoteInput::StartAccelCalibration(port);
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("Put the remote down on a flat surface with the buttons facing up and do not touch it\n"
"for about two seconds. Corrects the steering offset of a remote held sideways.");
}
ImGui::SameLine();
ImGui::BeginDisabled(!RuntimeConfigFile::HasWiiAccelOffset() || WiiRemoteInput::IsAccelCalibrating());
if (ImGui::Button("Clear calibration")) {
WiiRemoteInput::ClearAccelCalibration();
}
ImGui::EndDisabled();
if (const char* message = WiiRemoteInput::AccelCalibrationMessage()) {
ImGui::TextWrapped("%s", message);
} else if (RuntimeConfigFile::HasWiiAccelOffset()) {
const std::array<double, 3> offset = RuntimeConfigFile::WiiAccelOffset();
ImGui::TextDisabled("Stored offset: x %+.3f y %+.3f z %+.3f g", offset[0], offset[1], offset[2]);
} else {
ImGui::TextDisabled("Not calibrated (using SDL's zero point; see console.log for \"fallback accelerometer calibration\").");
}
}
// Wii Remotes (Bluetooth) menu: driver switch, pairing help, continuous scanning and the port's controller kind.
void DrawWiiRemoteSettings(uint32_t selectedGamePort) {
if (!ImGui::BeginMenu("Wii Remotes (Bluetooth)")) {
return;
}
if (ImGui::Checkbox("Use Wii Remotes / Wii U Pro Controllers", &g_wiiRemotesEnabled)) {
RuntimeConfigFile::SetWiiRemotesEnabled(g_wiiRemotesEnabled);
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("Takes effect on the next launch. Turn this off if you use a Mayflash DolphinBar.");
}
ImGui::TextDisabled("Pairing: Windows Settings > Bluetooth > Add device, then press 1+2");
ImGui::TextDisabled("(or the red SYNC button) on the remote. Leave the PIN empty.");
ImGui::TextDisabled("A remote that was paired before also needs to be turned on with 1+2/SYNC.");
if (ImGui::Checkbox("Keep scanning for Wii Remotes (like Dolphin's Continuous Scanning)",
&g_wiiContinuousScan)) {
RuntimeConfigFile::SetWiiContinuousScanEnabled(g_wiiContinuousScan);
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("While no Wii controller is connected, re-check Bluetooth every 2 seconds so a\n"
"remote that dropped out (\"Communications with the controller have been\n"
"interrupted\") or was turned on after launch comes back by itself.");
}
// The driver hint is only read at launch, so a rescan after the user turned
// the setting off would still re-enumerate Wii devices in this session.
ImGui::BeginDisabled(!g_wiiRemotesEnabled);
if (ImGui::Button("Rescan now")) {
WiiRemoteInput::RescanNow();
}
ImGui::EndDisabled();
ImGui::SameLine();
if (WiiRemoteInput::IsScanning()) {
ImGui::TextDisabled("Scanning... (%u so far) - press 1+2 on the remote", WiiRemoteInput::ScanCount());
} else {
ImGui::TextDisabled("Not scanning");
}
ImGui::Separator();
const WiiRemoteInput::Kind kind = WiiRemoteInput::KindForPort(selectedGamePort);
ImGui::Text("Port %u: %s", static_cast<unsigned>(selectedGamePort + 1), WiiRemoteInput::KindLabel(kind));
if (kind == WiiRemoteInput::Kind::RemoteWithClassic) {
WiiRemoteInput::KpadSample sample;
if (WiiRemoteInput::ReadKpadSample(selectedGamePort, sample)) {
// WPAD_CL_BUTTON_* bits, in the game's own layout (no mapping involved).
const auto held = [&](uint32_t bit, const char* on, const char* off) { return (sample.clHold & bit) ? on : off; };
ImGui::Text("Classic: %s %s %s %s %s %s %s %s %s %s %s %s %s %s", held(0x0010, "A", "a"),
held(0x0040, "B", "b"), held(0x0008, "X", "x"), held(0x0020, "Y", "y"), held(0x2000, "L", "l"),
held(0x0200, "R", "r"), held(0x0080, "ZL", "zl"), held(0x0004, "ZR", "zr"),
held(0x0400, "PLUS", "plus"), held(0x1000, "MINUS", "minus"), held(0x0001, "UP", "up"),
held(0x4000, "DOWN", "down"), held(0x0002, "LEFT", "left"), held(0x8000, "RIGHT", "right"));
ImGui::Text("Sticks: L %+.2f %+.2f (WPAD %+d %+d) R %+.2f %+.2f (WPAD %+d %+d)", sample.clLStick[0],
sample.clLStick[1], static_cast<int>(sample.clLStickRaw[0]),
static_cast<int>(sample.clLStickRaw[1]), sample.clRStick[0], sample.clRStick[1],
static_cast<int>(sample.clRStickRaw[0]), static_cast<int>(sample.clRStickRaw[1]));
ImGui::TextDisabled("Capitals = held. The game reads this Classic Controller through KPAD, as on the");
ImGui::TextDisabled("console: its buttons mean what the game says they mean, no mapping applies.");
}
}
if (kind == WiiRemoteInput::Kind::WiiUPro) {
if (SDL_Gamepad* gamepad = SDL_GetGamepadFromPlayerIndex(static_cast<int>(selectedGamePort))) {
// SDL's Wii driver posts the D-pad as joystick buttons 11-14 (the
// SDL_GAMEPAD_BUTTON_DPAD_* values) while its default HIDAPI mapping
// expects a hat, so SDL_GetGamepadButton never sees them; read the
// joystick directly, like the fallback in aurora's PADRead does.
SDL_Joystick* joystick = SDL_GetGamepadJoystick(gamepad);
const auto rawButton = [&](int index) {
return joystick != nullptr && SDL_GetJoystickButton(joystick, index);
};
ImGui::Text("Raw D-pad: %s %s %s %s", rawButton(SDL_GAMEPAD_BUTTON_DPAD_UP) ? "UP" : "up",
rawButton(SDL_GAMEPAD_BUTTON_DPAD_DOWN) ? "DOWN" : "down",
rawButton(SDL_GAMEPAD_BUTTON_DPAD_LEFT) ? "LEFT" : "left",
rawButton(SDL_GAMEPAD_BUTTON_DPAD_RIGHT) ? "RIGHT" : "right");
ImGui::Text("Raw face buttons: %s %s %s %s", rawButton(SDL_GAMEPAD_BUTTON_EAST) ? "A" : "a",
rawButton(SDL_GAMEPAD_BUTTON_SOUTH) ? "B" : "b", rawButton(SDL_GAMEPAD_BUTTON_NORTH) ? "X" : "x",
rawButton(SDL_GAMEPAD_BUTTON_WEST) ? "Y" : "y");
ImGui::Text("Raw ZL/ZR: %d / %d (pressed above 0)",
SDL_GetGamepadAxis(gamepad, SDL_GAMEPAD_AXIS_LEFT_TRIGGER),
SDL_GetGamepadAxis(gamepad, SDL_GAMEPAD_AXIS_RIGHT_TRIGGER));
ImGui::TextDisabled("Capitals = held. If a button never turns to capitals while physically held,");
ImGui::TextDisabled("that press is not reaching SDL at all (a driver-level issue, not a mapping one).");
ImGui::TextDisabled("This pad uses Nintendo's own layout (a/b/x/y as labelled); the shared");
ImGui::TextDisabled("button mapping above does not apply to it.");
}
}
if (kind == WiiRemoteInput::Kind::Remote || kind == WiiRemoteInput::Kind::RemoteWithNunchuk ||
kind == WiiRemoteInput::Kind::RemoteWithClassic) {
DrawWiiRemoteAccelerometer(selectedGamePort);
}
ImGui::EndMenu();
}
const char* KeyBindingName(int scancode) {
switch (scancode) {
case PAD_KEY_MOUSE_LEFT: return "Mouse left";
case PAD_KEY_MOUSE_RIGHT: return "Mouse right";
case PAD_KEY_MOUSE_MIDDLE: return "Mouse middle";
case PAD_KEY_MOUSE_X1: return "Mouse side 1";
case PAD_KEY_MOUSE_X2: return "Mouse side 2";
case PAD_KEY_INVALID: return "Unmapped";
default:
return scancode >= 0 && scancode < SDL_SCANCODE_COUNT
? SDL_GetScancodeName(static_cast<SDL_Scancode>(scancode)) : "Unknown";
}
}
enum class RebindKind { KeyboardButton, KeyboardAxis, Controller, MuteHotkey };
struct RebindState {
bool active = false;
bool openPopup = false;
RebindKind kind{};
uint32_t port = 0;
uint16_t target = 0;
bool secondary = false;
SDL_JoystickID instance = 0;
Clock::time_point deadline{};
std::string label;
std::array<bool, SDL_SCANCODE_COUNT> keys{};
uint32_t mouse = 0;
std::array<bool, SDL_GAMEPAD_BUTTON_COUNT> buttons{};
std::array<bool, SDL_GAMEPAD_AXIS_COUNT> axesReady{};
} g_rebind;
void BeginRebind(RebindKind kind, uint16_t target, const char* label, bool secondary = false) {
g_rebind = {};
g_rebind.active = true;
g_rebind.openPopup = true;
g_rebind.kind = kind;
g_rebind.port = static_cast<uint32_t>(g_controllerPort);
g_rebind.target = target;
g_rebind.secondary = secondary;
g_rebind.label = label;
g_rebind.deadline = Clock::now() + std::chrono::seconds(10);
int count = 0;
const bool* keys = SDL_GetKeyboardState(&count);
std::copy_n(keys, std::min(count, static_cast<int>(g_rebind.keys.size())), g_rebind.keys.begin());
g_rebind.mouse = SDL_GetMouseState(nullptr, nullptr);
const int index = PADGetIndexForPort(g_rebind.port);
if (kind == RebindKind::Controller && index >= 0) {
if (auto* pad = PADGetSDLGamepadForIndex(index)) {
g_rebind.instance = SDL_GetGamepadID(pad);
for (int i = 0; i < SDL_GAMEPAD_BUTTON_COUNT; ++i)
g_rebind.buttons[i] = SDL_GetGamepadButton(pad, static_cast<SDL_GamepadButton>(i));
for (int i = 0; i < SDL_GAMEPAD_AXIS_COUNT; ++i)
g_rebind.axesReady[i] = std::abs(static_cast<int>(SDL_GetGamepadAxis(pad, static_cast<SDL_GamepadAxis>(i)))) < 8000;
}
}
}
void CompleteRebind(uint32_t value) {
const auto& capture = g_rebind;
if (capture.kind == RebindKind::Controller) {
const int index = PADGetIndexForPort(capture.port);
auto* pad = index >= 0 ? PADGetSDLGamepadForIndex(index) : nullptr;
if (pad == nullptr || SDL_GetGamepadID(pad) != capture.instance) {
g_rebind.active = false;
return;
}
if (capture.secondary) PADSetAltButtonMapping(capture.port, {value, capture.target});
else PADSetButtonMapping(capture.port, {value, capture.target});
uint32_t count = 0, altCount = 0;
auto* primary = PADGetButtonMappings(capture.port, &count);
auto* alternate = PADGetAltButtonMappings(capture.port, &altCount);
uint32_t primaryValue = PAD_NATIVE_BUTTON_INVALID, alternateValue = PAD_NATIVE_BUTTON_INVALID;
for (uint32_t i = 0; i < count; ++i)
if (primary[i].padButton == capture.target) primaryValue = primary[i].nativeButton;
for (uint32_t i = 0; i < altCount; ++i)
if (alternate[i].padButton == capture.target) alternateValue = alternate[i].nativeButton;
std::string config = NativeBindingConfig(primaryValue);
if (alternateValue != PAD_NATIVE_BUTTON_INVALID) config += ',' + NativeBindingConfig(alternateValue);
for (size_t i = 0; i < kControllerButtons.size(); ++i)
if (kControllerButtons[i].padButton == capture.target) RuntimeConfigFile::SetControllerButton(i, config);
} else if (capture.kind == RebindKind::MuteHotkey) {
g_muteHotkey = static_cast<int32_t>(value);
RuntimeConfigFile::SetMuteHotkey(g_muteHotkey);
g_rebind.active = false;
return;
} else if (capture.kind == RebindKind::KeyboardButton) {
PADSetKeyButtonBinding(capture.port, {static_cast<int32_t>(value), capture.target});
} else {
PADSetKeyAxisBinding(capture.port, {static_cast<int32_t>(value), capture.target, 1});
}
PADSerializeMappings();
g_rebind.active = false;
}
void DrawRebindPrompt() {
if (g_rebind.openPopup) {
ImGui::OpenPopup("Rebind input");
g_rebind.openPopup = false;
}
if (!ImGui::BeginPopupModal("Rebind input", &g_rebind.active, ImGuiWindowFlags_AlwaysAutoResize)) {
g_rebind.active = false;
return;
}
if (g_rebind.active) {
ImGui::Text("Rebind: %s", g_rebind.label.c_str());
ImGui::TextUnformatted(g_rebind.kind == RebindKind::Controller
? "Press a controller button, pull a trigger, or move a stick."
: g_rebind.kind == RebindKind::MuteHotkey
? "Press a keyboard key."
: "Press a keyboard key or click a mouse button.");
ImGui::TextUnformatted("Release any held input first. Backspace or Delete clears the mapping.");
ImGui::TextUnformatted("Escape can be bound. F10 is reserved for settings.");
const float remaining = std::chrono::duration<float>(g_rebind.deadline - Clock::now()).count();
ImGui::Text("Unmapped in %d seconds", std::max(0, static_cast<int>(std::ceil(remaining))));
const bool clear = ImGui::Button("Clear mapping");
ImGui::SameLine();
if (ImGui::Button("Cancel")) g_rebind.active = false;
// UI clicks must not become mouse bindings (buttons activate on release).
const bool overControl = ImGui::IsAnyItemHovered();
if (g_rebind.active && (clear || remaining <= 0.0f)) {
CompleteRebind(g_rebind.kind == RebindKind::Controller ? PAD_NATIVE_BUTTON_DISABLED
: static_cast<uint32_t>(PAD_KEY_INVALID));
} else if (g_rebind.active && SDL_GetKeyboardFocus() != nullptr && g_rebind.kind != RebindKind::Controller) {
int count = 0;
const bool* keys = SDL_GetKeyboardState(&count);
for (int i = 1; i < std::min(count, static_cast<int>(SDL_SCANCODE_COUNT)) && g_rebind.active; ++i) {
if (keys[i] && !g_rebind.keys[i] && i != SDL_SCANCODE_F10) CompleteRebind(i);
g_rebind.keys[i] = keys[i];
}
const uint32_t mouse = SDL_GetMouseState(nullptr, nullptr);
for (int i = 1; i <= 5 && g_rebind.active; ++i)
if (!overControl && g_rebind.kind != RebindKind::MuteHotkey &&
(mouse & ~g_rebind.mouse & (1u << (i - 1))) != 0) CompleteRebind(static_cast<uint32_t>(-i - 1));
g_rebind.mouse = mouse;
} else if (g_rebind.active && SDL_GetKeyboardFocus() != nullptr && g_rebind.kind == RebindKind::Controller) {
auto* pad = SDL_GetGamepadFromID(g_rebind.instance);
if (pad != nullptr) {
for (int i = 0; i < SDL_GAMEPAD_BUTTON_COUNT && g_rebind.active; ++i) {
const bool pressed = SDL_GetGamepadButton(pad, static_cast<SDL_GamepadButton>(i));
if (pressed && !g_rebind.buttons[i]) CompleteRebind(i);
g_rebind.buttons[i] = pressed;
}
for (int i = 0; i < SDL_GAMEPAD_AXIS_COUNT && g_rebind.active; ++i) {
const int value = SDL_GetGamepadAxis(pad, static_cast<SDL_GamepadAxis>(i));
if (std::abs(value) < 8000) g_rebind.axesReady[i] = true;
if (g_rebind.axesReady[i] && std::abs(value) >= 16384)
CompleteRebind(PADEncodeAxisButton(i, value < 0));
}
}
}
}
if (!g_rebind.active) ImGui::CloseCurrentPopup();
ImGui::EndPopup();
}
void DrawKeyBinding(const char* label, int scancode, RebindKind kind, uint16_t target,
float width = 220.0f) {
const std::string caption = std::string(KeyBindingName(scancode)) + "##binding";
if (ImGui::Button(caption.c_str(), ImVec2(width, 0.0f))) BeginRebind(kind, target, label);
ImGui::SameLine(0.0f, ImGui::GetStyle().ItemInnerSpacing.x);
ImGui::TextUnformatted(label);
}
bool DrawKeyboardSettings(uint32_t port) {
uint32_t count = 0;
auto* buttons = PADGetKeyButtonBindings(port, &count);
bool enabled = buttons != nullptr;
bool usePreset = false;
if (ImGui::Checkbox("Keyboard and mouse", &enabled)) {
PADSetKeyboardActive(port, enabled);
PADSerializeMappings();
buttons = PADGetKeyButtonBindings(port, &count);
usePreset = enabled && std::all_of(buttons, buttons + count, [](const auto& binding) {
return binding.scancode == PAD_KEY_INVALID;
});
}
if (!enabled) return false;
ImGui::TextDisabled("Replaces the gamepad on this port. F10 opens settings.");
if (ImGui::Button("Use WASD + mouse preset") || usePreset) {
const std::array<int, PAD_BUTTON_COUNT> keys = {
PAD_KEY_MOUSE_LEFT, SDL_SCANCODE_SPACE, SDL_SCANCODE_E, SDL_SCANCODE_Q,
SDL_SCANCODE_RETURN, PAD_KEY_MOUSE_MIDDLE, SDL_SCANCODE_LSHIFT, PAD_KEY_MOUSE_RIGHT,
SDL_SCANCODE_UP, SDL_SCANCODE_DOWN, SDL_SCANCODE_LEFT, SDL_SCANCODE_RIGHT,
};
for (size_t i = 0; i < keys.size(); ++i)
PADSetKeyButtonBinding(port, {keys[i], kControllerButtons[i].padButton});
const std::array<int, PAD_AXIS_COUNT> axes = {
SDL_SCANCODE_D, SDL_SCANCODE_A, SDL_SCANCODE_W, SDL_SCANCODE_S,
SDL_SCANCODE_L, SDL_SCANCODE_J, SDL_SCANCODE_I, SDL_SCANCODE_K,
SDL_SCANCODE_LSHIFT, PAD_KEY_MOUSE_RIGHT,
};
uint32_t axisCount = 0;
auto* mappings = PADGetKeyAxisBindings(port, &axisCount);
for (uint32_t i = 0; i < axisCount; ++i)
PADSetKeyAxisBinding(port, {axes[i], mappings[i].padAxis, 1});
PADSerializeMappings();
}
ImGui::SeparatorText("Button mapping");
for (uint32_t i = 0; i < count; ++i) {
int key = buttons[i].scancode;
ImGui::PushID(static_cast<int>(i));
ImGui::SetNextItemWidth(220.0f);
DrawKeyBinding(PADGetButtonName(buttons[i].padButton), key, RebindKind::KeyboardButton, buttons[i].padButton);
ImGui::PopID();
}
ImGui::SeparatorText("Stick and trigger mapping");
uint32_t axisCount = 0;
auto* axes = PADGetKeyAxisBindings(port, &axisCount);
for (uint32_t i = 0; i < axisCount; ++i) {
int key = axes[i].scancode;
ImGui::PushID(static_cast<int>(count + i));
const char* direction = PADGetAxisDirectionLabel(axes[i].padAxis);
const std::string label = std::string(PADGetAxisName(axes[i].padAxis)) + " " +
(direction != nullptr ? direction : "");
ImGui::SetNextItemWidth(220.0f);
DrawKeyBinding(label.c_str(), key, RebindKind::KeyboardAxis, axes[i].padAxis);
ImGui::PopID();
}
return true;
}
// Controller settings menu: port selection, controller assignment and button mapping.
int ExpressionResizeCallback(ImGuiInputTextCallbackData* data) {
if (data->EventFlag == ImGuiInputTextFlags_CallbackResize) {
auto* text = static_cast<std::string*>(data->UserData);
text->resize(static_cast<size_t>(data->BufTextLen));
data->Buf = text->data();
}
return 0;
}
void DrawExpressionSettings() {
ImGui::SeparatorText("Expressions (Dolphin syntax)");
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + Scaled(440.0f));
ImGui::TextDisabled(
"Optional. An expression overrides nothing: its result is combined with the "
"button mapping above. Operators ! & | ^ and functions if, min, max, clamp, "
"timer, toggle, hold, tap, pulse, smooth, deadzone behave as they do in Dolphin.");
ImGui::PopTextWrapPos();
static std::array<std::string, InputBindings::kControls.size()> errors;
static std::array<std::string, InputBindings::kControls.size()> buffers;
static std::string importStatus;
static int loadedPort = -1;
static bool reloadBuffers = true;
const auto port = static_cast<uint32_t>(g_controllerPort);
if (loadedPort != g_controllerPort || reloadBuffers) {
for (size_t i = 0; i < buffers.size(); ++i) {
buffers[i] = InputBindings::GetExpression(port, i);
}
errors.fill(std::string());
loadedPort = g_controllerPort;
reloadBuffers = false;
}
if (ImGui::Button("Import from Dolphin")) {
const std::string path = InputBindings::DefaultDolphinConfigPath();
std::string summary;
std::string error;
if (InputBindings::ImportDolphinConfig(path, g_controllerPort + 1, port, summary, error) < 0) {
importStatus = error;
} else {
importStatus = summary;
errors.fill(std::string());
reloadBuffers = true;
}
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("Reads [GCPad%d] from %%APPDATA%%\\Dolphin Emulator\\Config\\GCPadNew.ini,\n"
"or GCPadNew.ini next to the executable.", g_controllerPort + 1);
}
if (!importStatus.empty()) {
ImGui::TextDisabled("%s", importStatus.c_str());
}
for (size_t i = 0; i < InputBindings::kControls.size(); ++i) {
ImGui::PushID(static_cast<int>(i) + 2000);
std::string& text = buffers[i];
ImGui::SetNextItemWidth(Scaled(300.0f));
if (ImGui::InputText(InputBindings::kControls[i].label, text.data(), text.capacity() + 1,
ImGuiInputTextFlags_EnterReturnsTrue | ImGuiInputTextFlags_CallbackResize,
ExpressionResizeCallback, &text)) {
std::string error;
errors[i] = InputBindings::SetExpression(port, i, text, error) ? std::string() : error;
}
if (InputBindings::IsActive(port, i)) {
ImGui::SameLine();
ImGui::TextColored(ImVec4(0.4f, 0.9f, 0.4f, 1.0f), "active");
}
if (!errors[i].empty()) {
ImGui::TextColored(ImVec4(1.0f, 0.65f, 0.3f, 1.0f), "%s", errors[i].c_str());
}
ImGui::PopID();
}
}
void DrawRumbleSettings() {
ImGui::SeparatorText("Vibration");
if (ImGui::Checkbox("Controller vibration", &g_rumbleEnabled)) {
PAD_HLE_SetRumbleEnabled(g_rumbleEnabled);
RuntimeConfigFile::SetRumbleEnabled(g_rumbleEnabled);
if (!g_rumbleEnabled) {
// Stop whatever is already running: the game will not send another
// motor command until its own state machine decides to.
constexpr std::array<uint32_t, PAD_MAX_CONTROLLERS> stopAll{
PAD_MOTOR_STOP_HARD, PAD_MOTOR_STOP_HARD, PAD_MOTOR_STOP_HARD, PAD_MOTOR_STOP_HARD,
};
PADControlAllMotors(stopAll.data());
mkw::vr::OpenXRSetWiiRemoteRumble(false);
}
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("Applies to every port.");
}
}
// What the headset's controllers are to the game. One choice for the pair, whichever port they
// land on, and live like the rest of the VR settings: without a session it is what the next starts on.
void DrawVrControllerSettings() {
if (ImGui::Combo("VR controllers", &g_vrControllerMode, kVrControllerModeLabels.data(),
static_cast<int>(kVrControllerModeLabels.size()))) {
mkw::vr::OpenXRSetControllerMode(static_cast<mkw::vr::OpenXRControllerMode>(g_vrControllerMode));
RuntimeConfigFile::SetVrControllerMode(kVrControllerModeNames[static_cast<size_t>(g_vrControllerMode)]);
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip(
"Wii Remote + Nunchuk: the right controller is a Wii Remote, with motion and a pointer "
"that lands where you aim on the virtual screen; the left one is the Nunchuk.\n"
" Right: A = A, trigger = B, B = C (look behind), stick up/down = 1/2\n"
" Left: stick = Nunchuk stick, trigger = Z, X or menu = +, Y = settings panel\n"
" The grips press nothing; they take hold of the wheel with hand steering.\n"
"Gamepad: both controllers are one ordinary controller, read as a GameCube pad.\n"
"None: the game does not see the VR controllers at all and they take no controller port,\n"
"so a desktop gamepad or a Wii Remote plays instead. Left Y still opens the settings panel.\n"
"Applies immediately; the game sees the controller change as a reconnection.");
}
if (mkw::vr::OpenXRIsRunning() &&
mkw::vr::OpenXRGetControllerMode() == mkw::vr::OpenXRControllerMode::WiiRemote) {
mkw::vr::OpenXRWiiRemoteSample remote;
if (mkw::vr::OpenXRReadWiiRemote(remote)) {
if (remote.pointer_valid) {
ImGui::TextDisabled("Pointer %+.2f %+.2f | Remote %+.2f %+.2f %+.2f g", remote.pointer[0],
remote.pointer[1], remote.acc[0], remote.acc[1], remote.acc[2]);
} else {
ImGui::TextDisabled("Pointer off screen | Remote %+.2f %+.2f %+.2f g", remote.acc[0],
remote.acc[1], remote.acc[2]);
}
}
}
}
void DrawControllerSettings() {
// First, since the per-port settings below stop early when a port has nothing to show.
DrawVrControllerSettings();
ImGui::Separator();
if (ImGui::CollapsingHeader("USB wheel and pedals (player 1)")) {
physical_wheel::DrawSettings();
ImGui::Separator();
}
for (int port = 0; port < PAD_MAX_CONTROLLERS; ++port) {
const std::string label = "Port " + std::to_string(port + 1);
ImGui::RadioButton(label.c_str(), &g_controllerPort, port);
if (port + 1 < PAD_MAX_CONTROLLERS) {
ImGui::SameLine();
}
}
ImGui::Separator();
const uint32_t selectedGamePort = static_cast<uint32_t>(g_controllerPort);
if (DrawKeyboardSettings(selectedGamePort)) {
return;
}
ImGui::Separator();
const char* currentName = PADGetName(selectedGamePort);
ImGui::Text("Assigned: %s", currentName != nullptr ? currentName : "None");
if (ImGui::MenuItem("Unassign controller")) {
PADClearPort(selectedGamePort);
g_configuredControllerIndices.fill(std::numeric_limits<int32_t>::min());
}
ImGui::Separator();
controller_mapping_wizard::DrawSetupList();
DrawWiiRemoteSettings(selectedGamePort);
const uint32_t controllerCount = PADCount();
if (controllerCount == 0) {
ImGui::TextDisabled("No controller connected");
return;
}
if (ImGui::BeginMenu("Assign connected controller")) {
for (uint32_t index = 0; index < controllerCount; ++index) {
const char* name = PADGetNameForControllerIndex(index);
ImGui::PushID(static_cast<int>(index));
if (ImGui::MenuItem(name != nullptr ? name : "Unknown controller")) {
PADSetPortForIndex(index, selectedGamePort);
g_configuredControllerIndices.fill(std::numeric_limits<int32_t>::min());
ApplyConfiguredMappings();
}
ImGui::PopID();
}
ImGui::EndMenu();
}
uint32_t mappingCount = 0;
PADButtonMapping* mappings = PADGetButtonMappings(static_cast<uint32_t>(g_controllerPort), &mappingCount);
if (mappings == nullptr || mappingCount != PAD_BUTTON_COUNT) {
ImGui::TextDisabled("Assign a controller to edit its buttons");
return;
}
uint32_t altMappingCount = 0;
PADButtonMapping* altMappings =
PADGetAltButtonMappings(static_cast<uint32_t>(g_controllerPort), &altMappingCount);
const auto writeBinding = [](size_t index, uint32_t primaryNative, uint32_t altNative) {
std::string value = NativeBindingConfig(primaryNative);
if (altNative != PAD_NATIVE_BUTTON_INVALID) {
value += ',';
value += NativeBindingConfig(altNative);
}
RuntimeConfigFile::SetControllerButton(index, value);
};
// Which rows show the second-binding combo without one being bound yet;
// reset when the user switches ports so a stale "+" click doesn't linger.
static std::array<bool, PAD_BUTTON_COUNT> altRowExpanded{};
static int altRowExpandedPort = -1;
if (altRowExpandedPort != g_controllerPort) {
altRowExpandedPort = g_controllerPort;
altRowExpanded.fill(false);
}
ImGui::SeparatorText("Presets");
if (ImGui::Button("GameCube")) {
const uint32_t port = static_cast<uint32_t>(g_controllerPort);
PADRestoreDefaultMapping(port);
uint32_t restoredCount = 0;
if (PADButtonMapping* restored = PADGetButtonMappings(port, &restoredCount)) {
for (size_t i = 0; i < kControllerButtons.size(); ++i) {
const auto it = std::find_if(restored, restored + restoredCount, [&](const PADButtonMapping& mapping) {
return mapping.padButton == kControllerButtons[i].padButton;
});
if (it != restored + restoredCount) {
RuntimeConfigFile::SetControllerButton(i, NativeButtonForValue(it->nativeButton).configName);
}
}
}
altRowExpanded.fill(false);
PADSerializeMappings();
mappings = PADGetButtonMappings(port, &mappingCount);
}
const auto applyPreset = [&](const std::array<const char*, PAD_BUTTON_COUNT>& preset) {
const uint32_t port = static_cast<uint32_t>(g_controllerPort);
for (size_t i = 0; i < kControllerButtons.size(); ++i) {
if (const NativeButtonItem* native = FindNativeButton(preset[i])) {
PADSetButtonMapping(port, PADButtonMapping{native->nativeButton, kControllerButtons[i].padButton});
PADSetAltButtonMapping(port,
PADButtonMapping{PAD_NATIVE_BUTTON_INVALID, kControllerButtons[i].padButton});
RuntimeConfigFile::SetControllerButton(i, preset[i]);
}
}
altRowExpanded.fill(false);
PADSerializeMappings();
mappings = PADGetButtonMappings(port, &mappingCount);
};
ImGui::SameLine();
if (ImGui::Button("Classic Controller Pro")) {
applyPreset(kClassicProPreset);
}
ImGui::SameLine();
if (ImGui::Button("PlayStation")) {
applyPreset(kPlayStationPreset);
}
ImGui::SeparatorText("Button mapping");
ImGui::TextDisabled("LT / L2 = left trigger. RT / R2 = right trigger.");
ImGui::TextDisabled("LB / L1 = left shoulder. RB / R1 = right shoulder.");
ImGui::TextDisabled("Click a binding, then press an input. No input for 10 seconds clears it.");
const float bindingWidth = ImGui::CalcTextSize("Right shoulder (RB / R1)").x +
ImGui::GetFrameHeight() + ImGui::GetStyle().FramePadding.x * 2.0f;
for (size_t i = 0; i < kControllerButtons.size(); ++i) {
auto mappingIt = std::find_if(mappings, mappings + mappingCount, [&](const PADButtonMapping& mapping) {
return mapping.padButton == kControllerButtons[i].padButton;
});
if (mappingIt == mappings + mappingCount) {
continue;
}
PADButtonMapping* altIt = nullptr;
if (altMappings != nullptr && altMappingCount == PAD_BUTTON_COUNT) {
const auto it = std::find_if(altMappings, altMappings + altMappingCount, [&](const PADButtonMapping& mapping) {
return mapping.padButton == kControllerButtons[i].padButton;
});
if (it != altMappings + altMappingCount) {
altIt = it;
}
}
const NativeButtonItem& current = NativeButtonForValue(mappingIt->nativeButton);
ImGui::PushID(static_cast<int>(i));
const auto drawThreshold = [&](PADButtonMapping* mapping, bool secondary) {
if (!PADIsAxisButton(mapping->nativeButton)) return;
int threshold = static_cast<int>(PADAxisButtonThreshold(mapping->nativeButton));
ImGui::SetNextItemWidth(bindingWidth);
if (ImGui::SliderInt(secondary ? "##altThreshold" : "##primaryThreshold", &threshold,
1, 100, "Threshold: %d%%", ImGuiSliderFlags_AlwaysClamp)) {
const PADButtonMapping updated = {
PADAxisButtonIdentity(mapping->nativeButton) | (static_cast<uint32_t>(threshold) << 8),
mapping->padButton,
};
if (secondary) PADSetAltButtonMapping(selectedGamePort, updated);
else PADSetButtonMapping(selectedGamePort, updated);
}
if (ImGui::IsItemDeactivatedAfterEdit()) {
writeBinding(i, mappingIt->nativeButton,
altIt != nullptr ? altIt->nativeButton : PAD_NATIVE_BUTTON_INVALID);
PADSerializeMappings();
}
};
ImGui::BeginGroup();
ImGui::SetNextItemWidth(bindingWidth);
const std::string primaryCaption = std::string(current.label) + "##primary";
if (ImGui::Button(primaryCaption.c_str(), ImVec2(bindingWidth, 0.0f))) {
BeginRebind(RebindKind::Controller, kControllerButtons[i].padButton, kControllerButtons[i].label);
}
drawThreshold(mappingIt, false);
ImGui::EndGroup();
if (altIt != nullptr) {
const bool altBound = altIt->nativeButton != PAD_NATIVE_BUTTON_INVALID;
if (!altBound && !altRowExpanded[i]) {
ImGui::SameLine();
if (ImGui::SmallButton("+")) {
altRowExpanded[i] = true;
BeginRebind(RebindKind::Controller, kControllerButtons[i].padButton, kControllerButtons[i].label, true);
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("Add a second binding; pressing either one works");
}
} else {
ImGui::SameLine();
ImGui::TextUnformatted("or");
ImGui::SameLine();
ImGui::BeginGroup();
const char* altLabel = altBound ? NativeButtonForValue(altIt->nativeButton).label : "None";
ImGui::SetNextItemWidth(bindingWidth);
const std::string altCaption = std::string(altLabel) + "##alt";
if (ImGui::Button(altCaption.c_str(), ImVec2(bindingWidth, 0.0f))) {
BeginRebind(RebindKind::Controller, kControllerButtons[i].padButton, kControllerButtons[i].label, true);
}
drawThreshold(altIt, true);
ImGui::EndGroup();
}
}
ImGui::SameLine();
ImGui::TextUnformatted(kControllerButtons[i].label);
ImGui::PopID();
}
DrawExpressionSettings();
DrawRumbleSettings();
}
void DrawAudioSettings() {
ImGui::SetNextItemWidth(Scaled(220.0f));
if (ImGui::SliderInt("Master", &g_audioVolumePercent, 0, 100, "%d%%")) {
const float volume = static_cast<float>(g_audioVolumePercent) / 100.0f;
AudioBackend::Instance().SetMasterVolume(volume);
RuntimeConfigFile::SetAudioVolume(volume);
}
if (ImGui::SliderInt("Music", &g_musicVolumePercent, 0, 100, "%d%%")) {
const float volume = static_cast<float>(g_musicVolumePercent) / 100.0f;
MusicAttenuation::SetMusicVolume(volume);
RuntimeConfigFile::SetMusicVolume(volume);
}
if (ImGui::SliderInt("Sound Effects", &g_soundEffectsVolumePercent, 0, 100, "%d%%")) {
const float volume = static_cast<float>(g_soundEffectsVolumePercent) / 100.0f;
MusicAttenuation::SetSoundEffectsVolume(volume);
RuntimeConfigFile::SetSoundEffectsVolume(volume);
}
if (ImGui::SliderInt("UI", &g_uiVolumePercent, 0, 100, "%d%%")) {
const float volume = static_cast<float>(g_uiVolumePercent) / 100.0f;
MusicAttenuation::SetUiVolume(volume);
RuntimeConfigFile::SetUiVolume(volume);
}
if (ImGui::SliderInt("Voices", &g_voicesVolumePercent, 0, 100, "%d%%")) {
const float volume = static_cast<float>(g_voicesVolumePercent) / 100.0f;
MusicAttenuation::SetVoicesVolume(volume);
RuntimeConfigFile::SetVoicesVolume(volume);
}
const float labelColumn = ImGui::GetCursorPosX() + ImGui::CalcItemWidth();
if (ImGui::Checkbox("Mute", &g_audioMuted)) {
AudioBackend::Instance().SetMuted(g_audioMuted);
RuntimeConfigFile::SetAudioMuted(g_audioMuted);
}
ImGui::SameLine();
DrawKeyBinding("Mute shortcut", g_muteHotkey, RebindKind::MuteHotkey, 0,
std::max(60.0f, labelColumn - ImGui::GetCursorPosX()));
ImGui::Separator();
if (ImGui::Checkbox("Mix audio on a worker thread", &g_audioMixWorker)) {
// Applies immediately: SetMixWorkerEnabled joins any in-flight mix
// before switching, so the change never lands mid-frame.
AxDspHle::SetMixWorkerEnabled(g_audioMixWorker);
RuntimeConfigFile::SetAudioMixWorker(g_audioMixWorker);
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip(
"Runs the AX/DSP voice mix off the game thread. Turn this off if you "
"suspect an audio problem; the mix then runs inline as it used to.");
}
ImGui::Separator();
if (ImGui::Checkbox("Mute game music while external media is playing",
&g_attenuateMusicWhenMediaPlays)) {
MusicAttenuation::SetEnabled(g_attenuateMusicWhenMediaPlays);
RuntimeConfigFile::SetAttenuateMusicWhenMediaPlays(g_attenuateMusicWhenMediaPlays);
}
if (g_attenuateMusicWhenMediaPlays) {
if (MusicAttenuation::IsExternalMediaPlaying()) {
ImGui::TextDisabled("External media is playing; game music is muted.");
} else if (!MusicAttenuation::IsMediaControlInitializationComplete()) {
ImGui::TextDisabled("Waiting for media controls...");
} else if (!MusicAttenuation::IsMediaControlAvailable()) {
ImGui::TextDisabled("Media controls are unavailable.");
} else {
ImGui::TextDisabled("No external media is currently playing.");
}
}
}
// The virtual screen's placement comes from the launch-time [vr] geometry, the
// same metres the menu quad is built from, converted into the world units the
// eye replay works in. Those units follow the camera: the first-person view
// renders at its own scale, and the screen has to be sized at the same one or
// it would not stay 2 m across in front of the player.
void ApplyVrHudVirtualScreen() {
const float unitsPerMeter = mkw::vr::MkwVRPolicyGetSnapshot().EffectiveUnitsPerMeter();
aurora_set_stereo_hud_screen(g_vrHudVirtualScreen,
RuntimeConfigFile::VrHudWidthMeters(2.4f) * unitsPerMeter,
RuntimeConfigFile::VrHudDistanceMeters(2.0f) * unitsPerMeter);
}
// The cockpit's steering wheel and hand steering, under the first-person camera.
void DrawVrSteeringWheelSettings() {
ImGui::Separator();
ImGui::Text("Steering wheel");
ImGui::BeginDisabled(g_vrFirstPersonSeat != 0);
if (ImGui::Checkbox("Turn the steering wheel", &g_vrSteeringWheel)) {
RuntimeConfigFile::SetVrSteeringWheel(g_vrSteeringWheel);
mkw::vr::MkwVRFirstPersonApplyConfiguredSettings();
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("In the cockpit, the kart's steering wheel or the bike's handlebar turns "
"with your steering.");
}
ImGui::BeginDisabled(!g_vrSteeringWheel);
if (ImGui::Checkbox("Use the vehicle's own wheel", &g_vrNativeSteeringWheel)) {
RuntimeConfigFile::SetVrNativeSteeringWheel(g_vrNativeSteeringWheel);
mkw::vr::MkwVRFirstPersonApplyConfiguredSettings();
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("Turns the wheel or handlebar of the vehicle's own model.");
}
if (ImGui::Checkbox("Placeholder wheel or handlebar", &g_vrPlaceholderSteeringWheel)) {
RuntimeConfigFile::SetVrPlaceholderSteeringWheel(g_vrPlaceholderSteeringWheel);
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("Draws a separate VR wheel (karts) or handlebar (bikes) whenever the "
"vehicle's own is not the one turning: with the option above off, or while "
"the game's draws do not take its animation, as in the race's opening pan.");
}
ImGui::EndDisabled();
if (ImGui::Checkbox("Hand steering (by heurazy)", &g_vrHandSteering)) {
RuntimeConfigFile::SetVrHandSteering(g_vrHandSteering);
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("Squeeze a grip near the wheel or handlebar to take hold of it, and turn "
"it to steer, with one hand or both. Releasing both grips gives steering "
"back to the stick, which still aims items.");
}
if (ImGui::Combo("Item in cockpit hand", &g_vrCockpitItemHand,
kVrCockpitItemHands.data(), static_cast<int>(kVrCockpitItemHands.size()))) {
RuntimeConfigFile::SetVrCockpitItemHand(
g_vrCockpitItemHand == 1 ? "right" : g_vrCockpitItemHand == 2 ? "off" : "left");
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("Show Player 1's settled inventory item above this palm. "
"Triple items show their remaining count. Using or losing the item hides it.");
}
ImGui::BeginDisabled(g_vrCockpitItemHand == 2);
if (ImGui::Checkbox("Throw the item by swinging that hand", &g_vrCockpitItemThrow)) {
RuntimeConfigFile::SetVrCockpitItemThrow(g_vrCockpitItemThrow);
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("With the hand off the wheel, swing it quickly forward to throw the item ahead "
"of the kart, or back to throw it behind: the stick and item button the game "
"reads for an aimed throw. Swinging while holding the item button throws the "
"trailed item.");
}
ImGui::EndDisabled();
#if defined(MKW_VR_STANDALONE)
ImGui::BeginDisabled(!g_vrHandSteering && g_vrCockpitItemHand == 2);
if (ImGui::Checkbox("Tracked hands", &g_vrHandTracking)) {
RuntimeConfigFile::SetVrHandTracking(g_vrHandTracking);
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip(
"%s", mkw::vr::OpenXRHandTrackingAvailable()
? "The cockpit hands follow your own. Holding the controllers, the fingers follow "
"their touch sensors; put one down and the cameras track that hand at once. Put "
"the controllers down to drive with bare hands: close a "
"hand on the wheel to hold it, which also holds the gas; pinch with a free hand "
"to use an item; flick your hands up for a trick; pinch with your left palm "
"facing you to pause. In menus, a right pinch is A. Choose Automatic drift. "
"Needs hand tracking on in the headset's settings."
: "The cockpit hands follow your own, from the controllers' touch sensors or, "
"with the controllers put down, the headset's cameras. This session started "
"with hand steering and tracked hands off, so it applies after a restart.");
}
ImGui::EndDisabled();
if (g_vrHandSteering && g_vrHandTracking) {
const mkw::vr::DrivingSnapshot driving = mkw::vr::OpenXRReadDriving();
const auto hand = [&](size_t side) {
const auto& state = driving.hands[side];
return std::string(mkw::vr::hand_tracking::SourceLabel(state.source)) +
(state.bare ? " (bare)" : "") + (state.held ? ", holding" : "") +
(state.pinch ? ", pinch" : "");
};
ImGui::TextDisabled("Hands: left %s, grasp %.2f | right %s, grasp %.2f", hand(0).c_str(),
driving.hands[0].grasp, hand(1).c_str(), driving.hands[1].grasp);
}
#endif
if (g_vrHandSteering && ImGui::TreeNode("Hand steering tuning")) {
bool changed = false;
changed |= ImGui::SliderFloat("Kart full lock (degrees)", &g_vrWheelTuning.kartDegrees,
RuntimeConfigFile::kVrWheelDegreesMin,
RuntimeConfigFile::kVrWheelDegreesMax, "%.0f");
changed |= ImGui::SliderFloat("Bike full lock (degrees)", &g_vrWheelTuning.bikeDegrees,
RuntimeConfigFile::kVrWheelDegreesMin,
RuntimeConfigFile::kVrWheelDegreesMax, "%.0f");
changed |= ImGui::SliderFloat("Grab reach (m)", &g_vrWheelTuning.grabDistance,
RuntimeConfigFile::kVrWheelGrabDistanceMin,
RuntimeConfigFile::kVrWheelGrabDistanceMax, "%.2f");
changed |= ImGui::SliderFloat("Grab assist", &g_vrWheelTuning.grabAssist,
RuntimeConfigFile::kVrWheelGrabAssistMin,
RuntimeConfigFile::kVrWheelGrabAssistMax, "%.2f");
changed |= ImGui::SliderFloat("Response", &g_vrWheelTuning.response,
RuntimeConfigFile::kVrWheelResponseMin,
RuntimeConfigFile::kVrWheelResponseMax, "%.2f");
changed |= ImGui::SliderFloat("Tracking-loss grace (s)", &g_vrWheelTuning.trackingGrace,
RuntimeConfigFile::kVrWheelTrackingGraceMin,
RuntimeConfigFile::kVrWheelTrackingGraceMax, "%.2f");
changed |= ImGui::Checkbox("Grab and release pulse", &g_vrWheelTuning.haptics);
if (ImGui::Button("Reset hand steering tuning")) {
g_vrWheelTuning = {};
changed = true;
}
if (changed) {
RuntimeConfigFile::SetVrWheelTuning(g_vrWheelTuning);
}
ImGui::TreePop();
}
ImGui::EndDisabled();
// What the cockpit found, for a report when the wheel does not behave.
const auto anchor = mkw::vr::MkwVRFirstPersonGetAnchor();
if (anchor.valid && anchor.cockpit) {
ImGui::TextDisabled("Cockpit: %s, %s, %.0f units/m, animated draws %u",
anchor.bike ? "handlebar" : "wheel",
!anchor.native_wheel.valid ? "grips not found"
: anchor.native_mesh_prepared ? "vehicle's own"
: g_vrSteeringWheel && g_vrPlaceholderSteeringWheel ? "placeholder"
: "vehicle's own, not animated",
anchor.units_per_meter, GxNativeWheel::LastDrawCount());
}
}
void DrawGraphicsSettings() {
g_displayMode = static_cast<int>(aurora_get_display_mode());
struct EffectFlag {
const char* label;
uint32_t flag;
};
static constexpr std::array<EffectFlag, 1> kEffectFlags = {{
{"Disable bloom", RuntimeConfigFile::kPostProcessingBloomPath},
}};
for (const auto& effect : kEffectFlags) {
bool disabled = (g_disabledPostProcessingPaths & effect.flag) != 0;
if (ImGui::Checkbox(effect.label, &disabled)) {
if (disabled) {
g_disabledPostProcessingPaths |= effect.flag;
} else {
g_disabledPostProcessingPaths &= ~effect.flag;
}
RuntimeGameGraphicsOptions::SetDisabledPostProcessingPaths(g_disabledPostProcessingPaths);
RuntimeConfigFile::SetDisabledPostProcessingPaths(g_disabledPostProcessingPaths);
}
}
ImGui::TextDisabled("Applied when the next scene renderer is created.");
ImGui::Separator();
static constexpr const char* kDisplayModes[] = {
"Windowed",
"Borderless fullscreen",
"Exclusive fullscreen",
};
if (ImGui::Combo("Display mode", &g_displayMode, kDisplayModes, static_cast<int>(std::size(kDisplayModes)))) {
const auto mode = static_cast<AuroraDisplayMode>(g_displayMode);
aurora_set_display_mode(mode);
const AuroraDisplayMode activeMode = aurora_get_display_mode();
if (activeMode == mode) {
RuntimeConfigFile::SetDisplayMode(std::string(kDisplayModeConfigNames[static_cast<size_t>(g_displayMode)]));
} else {
g_displayMode = static_cast<int>(activeMode);
}
}
if (g_displayMode == AURORA_DISPLAY_MODE_EXCLUSIVE) {
ImGui::TextDisabled(
"Requests the closest native-resolution display mode to the output frame "
"rate (60 Hz, or the frame interpolation target).");
}
constexpr std::array<const char*, 3> kFrameInterpolationModes{
"Off", "120 FPS", "180 FPS",
};
const char* currentFrameInterpolationMode =
kFrameInterpolationModes[static_cast<size_t>(g_frameInterpolationMode)];
bool frameInterpolationModeChanged = false;
if (ImGui::BeginCombo("Race frame interpolation (experimental)", currentFrameInterpolationMode)) {
for (int mode = 0; mode < static_cast<int>(kFrameInterpolationModes.size()); ++mode) {
const bool selected = g_frameInterpolationMode == mode;
if (ImGui::Selectable(kFrameInterpolationModes[static_cast<size_t>(mode)], selected)) {
g_frameInterpolationMode = mode;
frameInterpolationModeChanged = true;
}
if (selected) {
ImGui::SetItemDefaultFocus();
}
}
ImGui::EndCombo();
}
if (frameInterpolationModeChanged) {
const uint32_t targetFps = kFrameInterpolationTargetFps[static_cast<size_t>(g_frameInterpolationMode)];
aurora_set_frame_interpolation_fps(targetFps);
RuntimeConfigFile::SetFrameInterpolationFps(targetFps);
LimitResolutionForFrameRate();
if (aurora_get_display_mode() == AURORA_DISPLAY_MODE_EXCLUSIVE) {
// Re-apply exclusive mode so the display refresh tracks the new target.
aurora_set_display_mode(AURORA_DISPLAY_MODE_EXCLUSIVE);
}
}
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + Scaled(380.0f));
ImGui::TextDisabled("Frame interpolation is experimental, you might find visual artifacts");
ImGui::PopTextWrapPos();
if (ImGui::Checkbox("Disable copy filter", &g_disableCopyFilter)) {
aurora_set_disable_copy_filter(g_disableCopyFilter);
RuntimeConfigFile::SetDisableCopyFilter(g_disableCopyFilter);
}
if (ImGui::Checkbox("Skip draws while shaders compile", &g_skipUnreadyPipelines)) {
aurora_set_skip_unready_pipelines(g_skipUnreadyPipelines);
RuntimeConfigFile::SetSkipUnreadyPipelines(g_skipUnreadyPipelines);
}
if (ImGui::Checkbox("Show FPS", &g_showFps)) {
RuntimeConfigFile::SetShowFps(g_showFps);
}
ImGui::Separator();
ImGui::Text("Graphics API: %s", GraphicsApiDisplayName());
}
// The VR settings live in their own top-bar menu: they are a self-contained
// group, and keeping them out of Graphics stops that menu from running off the
// bottom of the screen.
void DrawVrSettings() {
const auto xrError = mkw::vr::OpenXRLastError();
if (!xrError.empty()) {
ImGui::TextWrapped("OpenXR unavailable: %s", xrError.c_str());
ImGui::TextWrapped("Playing on the desktop. Check your headset and active OpenXR runtime, then restart.");
}
if (ImGui::Checkbox("Enable OpenXR VR", &g_vrEnabled)) {
RuntimeConfigFile::SetVrEnabled(g_vrEnabled);
}
ImGui::TextDisabled("OpenXR mode changes take effect after restarting the game.");
// Everything below is live, unlike the enable toggle, so it is left usable either way: set
// before a restart it is simply what the next session starts on.
if (ImGui::Combo("Race view", &g_vrRaceView, kVrRaceViewLabels.data(),
static_cast<int>(kVrRaceViewLabels.size()))) {
const auto view = static_cast<RuntimeConfigFile::VrRaceView>(g_vrRaceView);
g_vrFlatScreen = view == RuntimeConfigFile::VrRaceView::FlatScreen;
RuntimeConfigFile::SetVrRaceView(view);
mkw::vr::MkwVRPolicySetImmersiveRaces(!g_vrFlatScreen);
mkw::vr::OpenXRSetImmersiveWindow(view == RuntimeConfigFile::VrRaceView::ImmersiveWindow);
mkw::vr::MkwVRFirstPersonApplyConfiguredSettings();
mkw::vr::MkwVRObjectCullingApplyConfiguredSettings();
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip(
"Immersive plays races all around you in stereo. Immersive window keeps that "
"stereo view but shows it only through a window where the menu screen sits, with "
"your room around it on the Quest (black elsewhere); look through it from another "
"angle and the view shifts as through a real window. Flat screen plays races on "
"the menu screen through the game's own camera; the Camera settings and the lean-back "
"angle do not apply to it. Applies immediately.");
}
ImGui::Separator();
// Applied when the slider is let go rather than at every step of a drag, since each new size
// means new eye swapchains.
ImGui::SliderInt("Render resolution", &g_vrRenderScalePercent,
static_cast<int>(RuntimeConfigFile::kVrRenderScaleMin * 100.0f),
static_cast<int>(RuntimeConfigFile::kVrRenderScaleMax * 100.0f), "%d%%",
ImGuiSliderFlags_AlwaysClamp);
if (ImGui::IsItemDeactivatedAfterEdit()) {
const float scale = static_cast<float>(g_vrRenderScalePercent) / 100.0f;
mkw::vr::OpenXRSetRenderScale(scale);
RuntimeConfigFile::SetVrRenderScale(scale);
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip(
"How many pixels each eye is rendered with, as a share of what your headset's OpenXR "
"runtime recommends. Lower is faster, higher is sharper, and the GPU's work grows with the "
"pixel count: 150%% draws about 2.25 times as many as 100%%.\n"
"Applies when you let go of the slider, without restarting; the headset keeps showing "
"the game while the eyes change size.");
}
if (const auto eyes = mkw::vr::OpenXRGetEyeResolution(static_cast<float>(g_vrRenderScalePercent) / 100.0f);
eyes.width != 0) {
if (eyes.width == eyes.scaled_width && eyes.height == eyes.scaled_height) {
ImGui::TextDisabled("Each eye: %u x %u", eyes.width, eyes.height);
} else {
ImGui::TextDisabled("Each eye: %u x %u now, %u x %u at %d%%", eyes.width, eyes.height, eyes.scaled_width,
eyes.scaled_height, g_vrRenderScalePercent);
}
}
#if defined(MKW_VR_STANDALONE)
#if defined(__ANDROID__) && !defined(MKW_HEADSET_STEAM_FRAME)
if (ImGui::Checkbox("Passthrough around the menu screen", &g_vrPassthrough)) {
mkw::vr::OpenXRSetPassthrough(g_vrPassthrough);
RuntimeConfigFile::SetVrPassthrough(g_vrPassthrough);
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip(
"Shows your room through the headset's cameras around the menu screen and every "
"other screen outside an immersive race, instead of black. Immersive races stay "
"fully virtual; the immersive window and the Flat Screen race have the room "
"around them too. Applies immediately.");
}
#endif
// Shows the live level Aurora holds.
g_vrFoveation = static_cast<int>(aurora_get_stereo_foveation());
if (ImGui::Combo("Foveated rendering", &g_vrFoveation, kVrFoveationLabels.data(),
static_cast<int>(kVrFoveationLabels.size()))) {
aurora_set_stereo_foveation(static_cast<uint32_t>(g_vrFoveation));
RuntimeConfigFile::SetVrFoveation(
std::string(RuntimeConfigFile::kVrFoveationLevels[static_cast<size_t>(g_vrFoveation)]));
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip(
"%s", aurora_stereo_foveation_available()
? "Shades the edges of the race view in 2x2, then 4x4 pixel blocks, where the "
"lenses blur the picture anyway, to free GPU time for a higher render scale or a "
"steadier frame rate. Higher levels start closer to the centre; High also "
"coarsens the corners of the HUD. Menus are never foveated. Applies immediately."
: "Shades the edges of the race view in 2x2, then 4x4 pixel blocks, where the "
"lenses blur the picture anyway, to free GPU time. This session started with it "
"off, or without a GPU that supports it: a new level applies after a restart.");
}
if (ImGui::Checkbox("Foveation follows the eyes", &g_vrEyeTrackedFoveation)) {
RuntimeConfigFile::SetVrEyeTrackedFoveation(g_vrEyeTrackedFoveation);
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip(
"With a headset that tracks the eyes (the Steam Frame), the sharp centre of the foveated "
"race view moves to where you look instead of staying straight ahead. Turning it off "
"applies immediately; turning it on needs a restart if the session started without it.");
}
#endif
{
const auto rateLabel = [](uint32_t hz) {
return hz == 0 ? std::string("Headset's own") : std::to_string(hz) + " Hz";
};
if (ImGui::BeginCombo("Headset refresh rate", rateLabel(g_vrRefreshRate).c_str())) {
for (const uint32_t hz : kVrRefreshRates) {
if (ImGui::Selectable(rateLabel(hz).c_str(), hz == g_vrRefreshRate) && hz != g_vrRefreshRate) {
g_vrRefreshRate = hz;
// The XR thread follows the setting and asks the runtime for it.
RuntimeConfigFile::SetVrRefreshRate(hz);
}
}
ImGui::EndCombo();
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip(
"Asks the headset for this display rate. The game runs at 60 FPS, so 120 Hz shows every "
"frame for exactly two refreshes, where 72 and 90 Hz hold some frames longer than others. "
"Only runtimes that let apps choose (XR_FB_display_refresh_rate: the Quest, the Steam "
"Frame, Virtual Desktop) take it; the Headset line below shows the rate in use. Applies "
"immediately.");
}
}
if (ImGui::Checkbox("Repeat frames at the headset's rate", &g_vrRepeatFrames)) {
RuntimeConfigFile::SetVrRepeatFrames(g_vrRepeatFrames);
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip(
"Shows the last frame again, turned to where you now look, on each refresh the game has "
"no new frame for. The headset's runtime then does not drop the game to half its rate and "
"fill the gaps itself, which doubles the HUD and the menu screen as you turn your head "
"(SteamVR on the Steam Frame). Applies immediately.");
}
#if !defined(_WIN32)
// Only the Vulkan backends show part of an eye image; the PC's copy whole eyes.
if (ImGui::Checkbox("Adaptive resolution (experimental)", &g_vrAdaptiveResolution)) {
RuntimeConfigFile::SetVrAdaptiveResolution(g_vrAdaptiveResolution);
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip(
"During races, lowers the eyes' resolution a step at a time, down to 70%%, while new "
"frames fall behind the game's 60 FPS, and raises it again once they keep up. Each change "
"rebuilds the foveation maps. Applies immediately.");
}
#endif
if (ImGui::Combo("VR frame interpolation (experimental)", &g_vrFrameInterpolationMode,
kVrInterpolationLabels.data(), static_cast<int>(kVrInterpolationLabels.size()))) {
const auto target = kVrInterpolationFps[static_cast<size_t>(g_vrFrameInterpolationMode)];
mkw::vr::OpenXRSetFrameInterpolationFps(target);
RuntimeConfigFile::SetVrFrameInterpolationFps(target);
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip(
"Auto matches the headset refresh rate. 72, 90 and 120 cap the scene rendering rate; "
"set the headset's refresh rate above, or in Virtual Desktop or your VR runtime. "
"The game stays at 60 Hz. Adds one game frame of scene latency; head tracking stays current. "
"Needs GPU headroom and may show interpolation artifacts. Applies immediately.");
}
const auto xrTiming = mkw::vr::OpenXRGetFrameTiming();
if (mkw::vr::OpenXRIsRunning()) {
ImGui::TextDisabled("Headset: %.1f Hz | New VR frames: %.1f FPS", xrTiming.headset_hz, xrTiming.rendered_fps);
if (g_vrFrameInterpolationMode != 0 && !mkw::vr::OpenXRFrameInterpolationAvailable()) {
ImGui::TextWrapped("The OpenXR runtime does not provide the clock conversion needed for interpolation.");
}
}
ImGui::Separator();
if (ImGui::Combo("Desktop view", &g_vrMirrorView, kVrMirrorViewLabels.data(),
static_cast<int>(kVrMirrorViewLabels.size()))) {
aurora_set_stereo_mirror_view(static_cast<AuroraStereoMirrorView>(g_vrMirrorView));
RuntimeConfigFile::SetVrMirrorView(kVrMirrorViewNames[static_cast<size_t>(g_vrMirrorView)]);
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip(
"What this window shows while the headset is running. Normal keeps the ordinary "
"desktop view, the eye choices mirror what you are actually seeing in the headset, "
"and None leaves the window black. Menus reach the headset as a screen showing this "
"same desktop image, so the eye choices only differ from Normal during a race.");
}
ImGui::Separator();
if (ImGui::Button("Recenter view")) {
mkw::vr::OpenXRRequestRecenter();
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip(
"Makes where you are sitting right now the centre of the view, and brings "
"the menu screen back upright in front of you. The race view moves in "
"position only, so the horizon stays level and forward is unchanged; use "
"your headset's own recenter to change forward. In cockpit view it also "
"remeasures the seat when the driver is straight, undamaged and normal size.");
}
ImGui::SameLine();
// Click to arm, then the next key press is captured in HandleEvents.
if (g_vrRecenterRebinding) {
if (ImGui::Button("Press a key... (Esc to cancel)###VrRecenterKey")) {
g_vrRecenterRebinding = false;
}
} else {
const char* name = g_vrRecenterScancode == SDL_SCANCODE_UNKNOWN
? nullptr
: SDL_GetScancodeName(g_vrRecenterScancode);
const std::string label =
std::string("Hotkey: ") + ((name && *name) ? name : "unbound") +
"###VrRecenterKey";
if (ImGui::Button(label.c_str())) {
g_vrRecenterRebinding = true;
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("Click to rebind. Esc cancels, Backspace unbinds.");
}
}
ImGui::BeginDisabled(g_vrFlatScreen);
if (ImGui::SliderFloat("Lean back angle (deg)", &g_vrLeanBackDegrees,
-RuntimeConfigFile::kVrLeanBackDegreesLimit,
RuntimeConfigFile::kVrLeanBackDegreesLimit, "%.1f")) {
mkw::vr::OpenXRSetLeanBackDegrees(g_vrLeanBackDegrees);
RuntimeConfigFile::SetVrLeanBackDegrees(g_vrLeanBackDegrees);
}
ImGui::EndDisabled();
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) {
ImGui::SetTooltip(
"Tilts the game camera back with you when you play reclined, so set it to "
"roughly how far back your seat is and the track comes back in front of you "
"instead of above. 0 applies no tilt. Unlike recentering, this deliberately "
"does pitch the view, and looking sideways while it is set will roll the "
"horizon the way a real recline would.");
}
ImGui::Separator();
ImGui::BeginDisabled(
g_vrFlatScreen || g_vrRaceView == static_cast<int>(RuntimeConfigFile::VrRaceView::ImmersiveWindow));
if (ImGui::Checkbox("2D layer on a virtual screen", &g_vrHudVirtualScreen)) {
ApplyVrHudVirtualScreen();
RuntimeConfigFile::SetVrHudVirtualScreen(g_vrHudVirtualScreen);
}
ImGui::EndDisabled();
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) {
ImGui::SetTooltip(
"Puts the minimap, race position, item roulette and the rest of the race HUD on a "
"screen fixed ahead of the kart camera. Turn off to leave them stretched across "
"the whole view. Its size and distance are the [vr] hud_width_meters and "
"hud_distance_meters read at launch. The immersive window is that screen, and "
"always carries them.");
}
ImGui::BeginDisabled(!mkw::vr::OpenXRIsRunning());
bool settingsPanelOpen = mkw::vr::OpenXRSettingsPanelOpen();
if (ImGui::Checkbox("Show these settings in the headset", &settingsPanelOpen)) {
mkw::vr::OpenXRSetSettingsPanelOpen(settingsPanelOpen);
}
ImGui::EndDisabled();
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) {
ImGui::SetTooltip(
"Opens these settings on a panel in front of you, in menus and races alike.\n"
"In the headset, left Y opens and closes it too (with Gamepad VR controllers,\n"
"click both thumbsticks together instead).\n"
"Aim at it and pull a trigger to change a setting; push a thumbstick to scroll.\n"
"While it is open the game does not see the VR controllers.");
}
}
// The first-person camera and its cockpit's steering wheel: the F10 bar's Camera menu and the
// headset panel's Camera tab.
void DrawVrCameraSettings() {
// All of it shapes the immersive race view, which Flat Screen mode (VR > Race view) replaces.
if (g_vrFlatScreen) {
ImGui::TextDisabled("Race view (under VR) is Flat screen: these apply to the immersive views.");
}
ImGui::BeginDisabled(g_vrFlatScreen);
if (ImGui::Checkbox("First-person camera", &g_vrFirstPerson)) {
RuntimeConfigFile::SetVrFirstPerson(g_vrFirstPerson);
mkw::vr::MkwVRFirstPersonApplyConfiguredSettings();
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("Also toggled by clicking the right thumbstick, on the VR controllers or on a gamepad.");
}
if (ImGui::Checkbox("Right thumbstick click toggles it", &g_vrFirstPersonToggleClick)) {
RuntimeConfigFile::SetVrFirstPersonToggleClick(g_vrFirstPersonToggleClick);
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip(
"Moves the camera to the Player 1 driver's head and keeps the horizon level, "
"instead of riding behind the kart. Applies during a single-screen race; menus "
"and split-screen are unaffected.");
}
constexpr std::array<const char*, 2> kSeatLabels{"Cockpit", "Custom"};
if (ImGui::Combo("Seat", &g_vrFirstPersonSeat, kSeatLabels.data(), static_cast<int>(kSeatLabels.size()))) {
RuntimeConfigFile::SetVrFirstPersonSeat(kVrFirstPersonSeatNames[static_cast<size_t>(g_vrFirstPersonSeat)]);
mkw::vr::MkwVRFirstPersonApplyConfiguredSettings();
ApplyVrHudVirtualScreen();
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip(
"Cockpit sits you at the driver's own eyes, behind the steering wheel, at a "
"life-size scale that allows for the character's height, so the wheel is within "
"reach. Custom places the head by the world scale and offsets below instead.");
}
if (g_vrFirstPersonSeat == 0) {
if (ImGui::SliderFloat("Cockpit scale (units per metre)", &g_vrCockpitUnitsPerMeter,
RuntimeConfigFile::kVrCockpitUnitsPerMeterMin,
RuntimeConfigFile::kVrCockpitUnitsPerMeterMax, "%.0f")) {
RuntimeConfigFile::SetVrCockpitUnitsPerMeter(g_vrCockpitUnitsPerMeter);
mkw::vr::MkwVRFirstPersonApplyConfiguredSettings();
ApplyVrHudVirtualScreen();
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip(
"100 reads life-size for an average-height driver; taller characters raise it "
"further on their own. Raising it shrinks the world around you.");
}
}
// These are the tuning loop for the anchor: the right head height is a
// per-taste value that can only really be judged from inside the headset.
ImGui::BeginDisabled(g_vrFirstPersonSeat == 0);
if (ImGui::SliderFloat("World units per metre (first person)", &g_vrFirstPersonUnitsPerMeter,
1.0f, 200.0f, "%.1f")) {
RuntimeConfigFile::SetVrFirstPersonUnitsPerMeter(g_vrFirstPersonUnitsPerMeter);
mkw::vr::MkwVRFirstPersonApplyConfiguredSettings();
// The virtual screen's metres are converted at this same scale.
ApplyVrHudVirtualScreen();
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip(
"Mario Kart Wii is authored at about 10 units per metre, which is what makes the "
"race read life-size. Raising this shrinks the world around you.");
}
bool headOffsetsChanged = false;
headOffsetsChanged |=
ImGui::SliderFloat("Head height (m)", &g_vrFirstPersonHeadUp, -RuntimeConfigFile::kVrFirstPersonHeadOffsetLimit, RuntimeConfigFile::kVrFirstPersonHeadOffsetLimit, "%.2f");
headOffsetsChanged |=
ImGui::SliderFloat("Head forward (m)", &g_vrFirstPersonHeadForward, -20.0f, 20.0f, "%.2f");
headOffsetsChanged |=
ImGui::SliderFloat("Head sideways (m)", &g_vrFirstPersonHeadRight, -RuntimeConfigFile::kVrFirstPersonHeadOffsetLimit, RuntimeConfigFile::kVrFirstPersonHeadOffsetLimit, "%.2f");
if (headOffsetsChanged) {
RuntimeConfigFile::SetVrFirstPersonHeadUpMeters(g_vrFirstPersonHeadUp);
RuntimeConfigFile::SetVrFirstPersonHeadForwardMeters(g_vrFirstPersonHeadForward);
RuntimeConfigFile::SetVrFirstPersonHeadRightMeters(g_vrFirstPersonHeadRight);
mkw::vr::MkwVRFirstPersonApplyConfiguredSettings();
}
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + Scaled(380.0f));
ImGui::TextDisabled("Custom seat: where the head sits in the kart's own frame.");
ImGui::PopTextWrapPos();
ImGui::EndDisabled();
constexpr std::array<const char*, 3> kRotationLabels{"Yaw only", "Yaw + Pitch", "Full rotation"};
if (ImGui::Combo("View rotation", &g_vrFirstPersonRotation, kRotationLabels.data(),
static_cast<int>(kRotationLabels.size()))) {
RuntimeConfigFile::SetVrFirstPersonRotation(
kVrFirstPersonRotationNames[static_cast<size_t>(g_vrFirstPersonRotation)]);
mkw::vr::MkwVRFirstPersonApplyConfiguredSettings();
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip(
"Where the view's orientation comes from. Yaw only keeps the horizon level "
"and is the comfortable choice. Yaw + Pitch adds the kart's climb, so slopes "
"and wheelies tip the view without ever rolling it. Full rotation takes the "
"kart's whole orientation, banking included. The headset always adds free look "
"on top.");
}
// Two presentations of one setting: which models go, or none at all.
// Ticking either replaces the other, and unticking both shows everything.
const auto applyHiding = [](bool enabled, int model) {
g_vrFirstPersonHideDriver = enabled;
if (enabled) {
g_vrFirstPersonHiddenModel = model;
RuntimeConfigFile::SetVrFirstPersonHiddenModel(model);
}
RuntimeConfigFile::SetVrFirstPersonHideDriver(enabled);
mkw::vr::MkwVRFirstPersonApplyConfiguredSettings();
};
bool hideDriver = g_vrFirstPersonHideDriver && g_vrFirstPersonHiddenModel >= 0;
bool hideDriverAndKart = g_vrFirstPersonHideDriver && g_vrFirstPersonHiddenModel < 0;
if (ImGui::Checkbox("Hide driver", &hideDriver)) {
applyHiding(hideDriver, RuntimeConfigFile::kVrFirstPersonHiddenModelDefault);
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip(
"Removes your character and leaves the kart around you. Their head would "
"otherwise be where your eyes are. Other racers are unaffected.");
}
if (ImGui::Checkbox("Hide driver and kart", &hideDriverAndKart)) {
applyHiding(hideDriverAndKart, -1);
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("Removes the vehicle as well, leaving nothing of your own kart.");
}
DrawVrSteeringWheelSettings();
ImGui::Separator();
ImGui::Text("Object culling");
if (ImGui::Checkbox("Hide what the game camera cannot see", &g_vrObjectCulling)) {
RuntimeConfigFile::SetVrObjectCulling(g_vrObjectCulling);
mkw::vr::MkwVRObjectCullingApplyConfiguredSettings();
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip(
"The game's own culling: karts, characters and course objects outside its chase "
"camera's view are not drawn, so a head turn or a look over the shoulder finds them "
"missing. Off draws them anyway, at some GPU cost, and is the PC's default. The draw "
"distance is unchanged, and the Flat screen race view always keeps the game's culling.");
}
ImGui::Separator();
if (ImGui::Button("Reset first-person defaults")) {
g_vrFirstPersonSeat = 0;
g_vrCockpitUnitsPerMeter = RuntimeConfigFile::kVrCockpitUnitsPerMeterDefault;
g_vrSteeringWheel = RuntimeConfigFile::kVrSteeringWheelDefault;
g_vrNativeSteeringWheel = RuntimeConfigFile::kVrNativeSteeringWheelDefault;
g_vrPlaceholderSteeringWheel = RuntimeConfigFile::kVrPlaceholderSteeringWheelDefault;
g_vrHandSteering = RuntimeConfigFile::kVrHandSteeringDefault;
g_vrCockpitItemHand = 0;
g_vrCockpitItemThrow = RuntimeConfigFile::kVrCockpitItemThrowDefault;
RuntimeConfigFile::SetVrFirstPersonSeat(RuntimeConfigFile::kVrFirstPersonSeatDefault);
RuntimeConfigFile::SetVrCockpitUnitsPerMeter(g_vrCockpitUnitsPerMeter);
RuntimeConfigFile::SetVrSteeringWheel(g_vrSteeringWheel);
RuntimeConfigFile::SetVrNativeSteeringWheel(g_vrNativeSteeringWheel);
RuntimeConfigFile::SetVrPlaceholderSteeringWheel(g_vrPlaceholderSteeringWheel);
RuntimeConfigFile::SetVrHandSteering(g_vrHandSteering);
RuntimeConfigFile::SetVrCockpitItemHand(RuntimeConfigFile::kVrCockpitItemHandDefault);
RuntimeConfigFile::SetVrCockpitItemThrow(g_vrCockpitItemThrow);
#if defined(MKW_VR_STANDALONE)
g_vrHandTracking = RuntimeConfigFile::kVrHandTrackingDefault;
RuntimeConfigFile::SetVrHandTracking(g_vrHandTracking);
#endif
g_vrFirstPersonUnitsPerMeter = RuntimeConfigFile::kVrFirstPersonUnitsPerMeterDefault;
g_vrFirstPersonHeadUp = RuntimeConfigFile::kVrFirstPersonHeadUpDefault;
g_vrFirstPersonHeadForward = RuntimeConfigFile::kVrFirstPersonHeadForwardDefault;
g_vrFirstPersonHeadRight = RuntimeConfigFile::kVrFirstPersonHeadRightDefault;
g_vrFirstPersonHideDriver = RuntimeConfigFile::kVrFirstPersonHideDriverDefault;
g_vrFirstPersonHiddenModel = RuntimeConfigFile::kVrFirstPersonHiddenModelDefault;
g_vrFirstPersonRotation =
VrFirstPersonRotationIndex(RuntimeConfigFile::kVrFirstPersonRotationDefault);
RuntimeConfigFile::SetVrFirstPersonRotation(
RuntimeConfigFile::kVrFirstPersonRotationDefault);
RuntimeConfigFile::SetVrFirstPersonUnitsPerMeter(g_vrFirstPersonUnitsPerMeter);
RuntimeConfigFile::SetVrFirstPersonHeadUpMeters(g_vrFirstPersonHeadUp);
RuntimeConfigFile::SetVrFirstPersonHeadForwardMeters(g_vrFirstPersonHeadForward);
RuntimeConfigFile::SetVrFirstPersonHeadRightMeters(g_vrFirstPersonHeadRight);
RuntimeConfigFile::SetVrFirstPersonHideDriver(g_vrFirstPersonHideDriver);
RuntimeConfigFile::SetVrFirstPersonHiddenModel(g_vrFirstPersonHiddenModel);
mkw::vr::MkwVRFirstPersonApplyConfiguredSettings();
ApplyVrHudVirtualScreen();
}
ImGui::EndDisabled();
}
// The right-thumbstick click: flips the first-person camera exactly as its
// checkbox does, so it does nothing in Flat Screen mode either (it does in the
// immersive window, which is still the stereo race view). Game thread.
void ToggleFirstPersonCamera() {
if (g_vrFlatScreen) {
return;
}
g_vrFirstPerson = !g_vrFirstPerson;
RuntimeConfigFile::SetVrFirstPerson(g_vrFirstPerson);
mkw::vr::MkwVRFirstPersonApplyConfiguredSettings();
RT_LOG(RT_TAG_RUNTIME) << "[mkw-vr] first-person camera " << (g_vrFirstPerson ? "on" : "off")
<< " (right thumbstick click)" << std::endl;
}
// A gamepad whose right thumbstick click reaches the game (bound to a
// GameCube control on its port) keeps it; toggling the camera as well would
// fire both.
bool RightStickDrivesGame(SDL_Gamepad* gamepad) {
if (gamepad == nullptr) {
return false;
}
for (uint32_t port = 0; port < PAD_MAX_CONTROLLERS; ++port) {
const s32 index = PADGetIndexForPort(port);
if (index < 0 || PADGetSDLGamepadForIndex(static_cast<u32>(index)) != gamepad) {
continue;
}
for (auto* mappings : {&PADGetButtonMappings, &PADGetAltButtonMappings}) {
u32 count = 0;
const PADButtonMapping* list = (*mappings)(port, &count);
for (u32 i = 0; list != nullptr && i < count; ++i) {
if (list[i].nativeButton == SDL_GAMEPAD_BUTTON_RIGHT_STICK) {
return true;
}
}
}
for (size_t control = 0; control < InputBindings::kControls.size(); ++control) {
const std::string expression = InputBindings::GetExpression(port, control);
if (expression.find("Thumb R") != std::string::npos || expression.find("Button 11") != std::string::npos) {
return true;
}
}
}
return false;
}
// A physical gamepad's right-thumbstick click, while VR runs.
void HandleGamepadFirstPersonClick(const SDL_GamepadButtonEvent& event) {
const bool right = event.button == SDL_GAMEPAD_BUTTON_RIGHT_STICK;
const bool left = event.button == SDL_GAMEPAD_BUTTON_LEFT_STICK;
if ((!right && !left) || mkw::vr::OpenXRIsControllerGamepad(event.which)) {
return;
}
SDL_Gamepad* gamepad = SDL_GetGamepadFromID(event.which);
auto& click = g_gamepadFirstPersonClicks[event.which];
const bool held = right ? event.down : click.Held();
const bool partner = left ? event.down : gamepad != nullptr && SDL_GetGamepadButton(gamepad, SDL_GAMEPAD_BUTTON_LEFT_STICK);
const bool blocked = g_rebind.active || InputBindings::InputBlocked();
if (click.Update(held, partner, blocked) && g_vrFirstPersonToggleClick && mkw::vr::OpenXRIsRunning() &&
!RightStickDrivesGame(gamepad)) {
ToggleFirstPersonCamera();
}
}
// Export Logs. SDL shows the folder picker without blocking the game and calls
// back on a thread of its choosing (its own dialog thread on Windows), where the
// copy then runs; the menu only reads the outcome through this state.
struct LogExportState {
std::mutex mutex;
std::string note;
std::string message;
bool failed = false;
};
LogExportState g_logExport;
std::atomic_bool g_logExportInProgress{false};
void SetLogExportMessage(std::string message, bool failed) {
std::lock_guard lock(g_logExport.mutex);
g_logExport.message = std::move(message);
g_logExport.failed = failed;
}
// Captured on the click, so the export describes the moment the player asked.
std::string BuildLogExportNote() {
#if defined(_WIN32)
const unsigned long pid = ::GetCurrentProcessId();
#else
const auto pid = static_cast<unsigned long>(::getpid());
#endif
std::ostringstream note;
note << "Exported by process " << pid << "; its run folder under Logs ends in _pid" << pid << ".\n"
<< "OpenXR diagnostic logging: " << (g_openxrDiagnosticsLogging ? "on" : "off") << '\n'
<< "OpenXR running: " << (mkw::vr::OpenXRIsRunning() ? "yes" : "no") << '\n';
if (const auto xrError = mkw::vr::OpenXRLastError(); !xrError.empty()) {
note << "OpenXR last error: " << xrError << '\n';
}
return note.str();
}
void SDLCALL OnLogExportFolderChosen(void*, const char* const* filelist, int) {
// SDL's C caller must never see an exception.
try {
if (filelist == nullptr) {
SetLogExportMessage(std::string("Could not open the folder picker: ") + SDL_GetError(), true);
} else if (filelist[0] == nullptr) {
SetLogExportMessage("Export canceled.", false);
} else {
std::string note;
{
std::lock_guard lock(g_logExport.mutex);
note = g_logExport.note;
}
SetLogExportMessage("Exporting...", false);
const auto result = log_export::ExportLogs(
RuntimeConfigFile::ApplicationDataDirectory() / "Logs", RuntimeConfigFile::ResolveConfigPath(),
RuntimeConfigFile::PathFromUtf8(filelist[0]), note, std::chrono::system_clock::now());
const std::string destination = RuntimeConfigFile::PathToUtf8(result.destination);
if (result.Succeeded()) {
SetLogExportMessage("Exported " + std::to_string(result.files_copied) + " files to " + destination,
false);
} else if (!result.destination.empty()) {
SetLogExportMessage("Exported " + std::to_string(result.files_copied) + " files to " + destination +
", but " + std::to_string(result.files_failed) +
" could not be copied: " + result.error,
true);
} else {
SetLogExportMessage("Export failed: " + result.error, true);
}
RT_LOG(RT_TAG_RUNTIME) << "Log export to " << destination << ": " << result.files_copied
<< " file(s) copied, " << result.files_failed << " failed"
<< (result.error.empty() ? "" : " (" + result.error + ")") << std::endl;
}
} catch (const std::exception& exception) {
SetLogExportMessage(std::string("Export failed: ") + exception.what(), true);
} catch (...) {
SetLogExportMessage("Export failed.", true);
}
g_logExportInProgress.store(false, std::memory_order_release);
}
void StartLogExport() {
bool expected = false;
if (!g_logExportInProgress.compare_exchange_strong(expected, true, std::memory_order_acq_rel)) {
return;
}
{
std::lock_guard lock(g_logExport.mutex);
g_logExport.note = BuildLogExportNote();
}
SetLogExportMessage("Choose the folder to export the logs into.", false);
// Parented to the game window so the picker opens in front of it. SDL may
// call back before returning if the dialog cannot be shown at all.
SDL_ShowOpenFolderDialog(&OnLogExportFolderChosen, nullptr, SDL_GetKeyboardFocus(), nullptr, false);
}
void DrawDiagnosticsSettings() {
if (ImGui::Checkbox("OpenXR diagnostic logging", &g_openxrDiagnosticsLogging)) {
mkw::vr::diagnostics::SetEnabled(g_openxrDiagnosticsLogging);
RuntimeConfigFile::SetDiagnosticsOpenXRLogging(g_openxrDiagnosticsLogging);
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip(
"Writes VR frame timing to console.log once per second: late, skipped, repeated and\n"
"empty (black) frames, how long each frame waited for the game and for rendering,\n"
"head-tracking loss, reference-space changes, and the headset's view layout.\n"
"Turn it on to report stutter or black frames in VR, and off again afterwards.\n"
"Off by default. Applies immediately and is remembered.");
}
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + Scaled(380.0f));
if (g_openxrDiagnosticsLogging && !mkw::vr::OpenXRIsRunning()) {
ImGui::TextDisabled("OpenXR is not running, so nothing is logged until a VR session starts.");
}
ImGui::PopTextWrapPos();
if (ImGui::Checkbox("First-person camera logging", &g_firstPersonDiagnosticsLogging)) {
RuntimeConfigFile::SetDiagnosticsFirstPersonLogging(g_firstPersonDiagnosticsLogging);
mkw::vr::MkwVRFirstPersonApplyConfiguredSettings();
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip(
"Writes the first-person camera's anchor, view and kart pose, and the cockpit's\n"
"wheel and view checks, to console.log once per second during a race.\n"
"For reporting a misplaced first-person view. Off by default; it costs a little\n"
"game-thread time while on. Applies immediately and is remembered.");
}
ImGui::Separator();
const bool exporting = g_logExportInProgress.load(std::memory_order_acquire);
ImGui::BeginDisabled(exporting);
if (ImGui::Button("Export Logs")) {
StartLogExport();
}
ImGui::EndDisabled();
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) {
ImGui::SetTooltip(
"Choose a folder, and the logs of recent sessions (the last four days, this one\n"
"included) are copied into a new WiiCompiled-logs folder there, together with\n"
"Config.toml. Zip that folder to attach it to a bug report.");
}
std::string message;
bool failed = false;
{
std::lock_guard lock(g_logExport.mutex);
message = g_logExport.message;
failed = g_logExport.failed;
}
if (!message.empty()) {
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + Scaled(380.0f));
if (failed) {
ImGui::TextColored(ImVec4(1.0f, 0.45f, 0.35f, 1.0f), "%s", message.c_str());
} else {
ImGui::TextDisabled("%s", message.c_str());
}
ImGui::PopTextWrapPos();
}
}
bool g_updatePromptOpen = false;
std::string g_updateError;
void DrawUpdatePrompt() {
constexpr const char* kTitle = "Update";
if (g_updatePromptOpen && !ImGui::IsPopupOpen(kTitle)) ImGui::OpenPopup(kTitle);
if (!ImGui::BeginPopupModal(kTitle, &g_updatePromptOpen, ImGuiWindowFlags_AlwaysAutoResize)) return;
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + Scaled(380.0f));
ImGui::TextUnformatted("The update is built here on the Frame, in the background, which takes a "
"while: keep the Frame on its charger. You can keep playing meanwhile, "
"though the game may stutter while it compiles. Close the game whenever "
"you like: the update carries on and opens it again when it is done.");
ImGui::PopTextWrapPos();
if (ImGui::Button("Start the update", ImVec2(Scaled(180.0f), 0.0f))) {
update_check::StartUpdate(g_updateError);
g_updatePromptOpen = false;
}
ImGui::SameLine();
if (ImGui::Button("Cancel", ImVec2(Scaled(120.0f), 0.0f))) g_updatePromptOpen = false;
if (!g_updatePromptOpen) ImGui::CloseCurrentPopup();
ImGui::EndPopup();
}
void DrawUpdateSettings() {
const update_check::Status status = update_check::Current();
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + Scaled(380.0f));
if (status.installedRelease.empty()) {
ImGui::TextUnformatted("This build came from a source tree rather than a release.");
} else {
ImGui::Text("Release %s", status.installedRelease.c_str());
}
const bool busy = status.state == update_check::State::Checking ||
status.state == update_check::State::Updating;
switch (status.state) {
case update_check::State::PcInstall:
ImGui::TextDisabled("The PC that built this installs its updates. Run the installer there "
"again with --update.");
break;
case update_check::State::Idle:
break;
case update_check::State::Checking:
ImGui::TextDisabled("Looking for a newer release...");
break;
case update_check::State::UpToDate:
ImGui::TextDisabled("%s", status.detail.c_str());
break;
case update_check::State::Available:
ImGui::TextUnformatted(status.detail.c_str());
break;
case update_check::State::Updating:
ImGui::Text("Updating: %s", status.detail.c_str());
ImGui::TextDisabled("Keep playing, or close the game: the update carries on and opens it "
"again when it is done.");
break;
case update_check::State::Failed:
ImGui::TextColored(ImVec4(1.0f, 0.45f, 0.35f, 1.0f), "%s", status.detail.c_str());
break;
}
if (!g_updateError.empty()) {
ImGui::TextColored(ImVec4(1.0f, 0.45f, 0.35f, 1.0f), "%s", g_updateError.c_str());
}
if (status.restartNeeded && status.state != update_check::State::Updating) {
// The update replaced the build on disk; this process is still the old one.
ImGui::TextUnformatted("The update is installed. Quit and start the game again to use it.");
}
ImGui::PopTextWrapPos();
if (status.state == update_check::State::PcInstall) {
return;
}
if (status.restartNeeded && status.state != update_check::State::Updating) {
if (ImGui::Button("Quit")) g_exitPromptOpen = true;
ImGui::SameLine();
}
ImGui::BeginDisabled(busy);
if (ImGui::Button(status.state == update_check::State::Idle ? "Check for updates" : "Check again")) {
g_updateError.clear();
update_check::StartCheck();
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("Asks github.com for the newest release, and Retro Rewind's server for\n"
"its newest pack when this install has one. Nothing is downloaded\n"
"until you choose to update.");
}
if (status.state == update_check::State::Available) {
ImGui::SameLine();
if (ImGui::Button("Update now")) {
g_updateError.clear();
g_updatePromptOpen = true;
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("Builds the update here on the Frame, in the background. The game\n"
"can stay open and shows the progress here.");
}
}
ImGui::EndDisabled();
// The prompt itself is drawn at the top level, as the exit prompt is: the tab lives both in the
// headset's panel and in a desktop menu, and a modal must outlive whichever opened it.
}
void DrawFpsOverlay() {
AuroraPresentTiming presentTiming{};
aurora_get_present_timing(&presentTiming);
if (!g_showFps) {
return;
}
const ImGuiIO& io = ImGui::GetIO();
constexpr float kMargin = 10.0f;
const float top = g_topBarVisible ? ImGui::GetFrameHeight() + kMargin : kMargin;
ImGui::SetNextWindowPos(ImVec2(io.DisplaySize.x - kMargin, top), ImGuiCond_Always, ImVec2(1.0f, 0.0f));
ImGui::SetNextWindowBgAlpha(0.55f);
constexpr ImGuiWindowFlags kFlags = ImGuiWindowFlags_AlwaysAutoResize |
ImGuiWindowFlags_NoDecoration |
ImGuiWindowFlags_NoFocusOnAppearing |
ImGuiWindowFlags_NoInputs |
ImGuiWindowFlags_NoMove |
ImGuiWindowFlags_NoNav |
ImGuiWindowFlags_NoSavedSettings;
if (ImGui::Begin("FPS Overlay", nullptr, kFlags)) {
if (presentTiming.sampleCount == 0) {
ImGui::TextUnformatted("FPS: --");
} else {
// Present timing includes the additional frames produced by
// interpolation, so this remains the actual displayed FPS.
ImGui::Text("FPS: %.1f", presentTiming.framesPerSecond);
// Replay-unsafe frames hold the presented cadence with duplicated
// slots, so the counter alone reads 180 while the motion on screen
// is 60 Hz. Surface the divergence instead of hiding it.
if (presentTiming.effectiveFramesPerSecond <
presentTiming.framesPerSecond * 0.95) {
ImGui::TextColored(ImVec4(1.0f, 0.75f, 0.25f, 1.0f), "Motion: %.1f",
presentTiming.effectiveFramesPerSecond);
}
}
}
ImGui::End();
}
void DrawShaderCompilationStatus() {
const uint32_t queuedPipelines = aurora_get_queued_pipeline_count();
if (queuedPipelines == 0) {
return;
}
constexpr float kMargin = 10.0f;
const float top = g_topBarVisible ? ImGui::GetFrameHeight() + kMargin : kMargin;
ImGui::SetNextWindowPos(ImVec2(kMargin, top), ImGuiCond_Always);
ImGui::SetNextWindowBgAlpha(0.55f);
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(7.0f, 4.0f));
constexpr ImGuiWindowFlags kFlags = ImGuiWindowFlags_AlwaysAutoResize |
ImGuiWindowFlags_NoDecoration |
ImGuiWindowFlags_NoFocusOnAppearing |
ImGuiWindowFlags_NoInputs |
ImGuiWindowFlags_NoMove |
ImGuiWindowFlags_NoNav |
ImGuiWindowFlags_NoSavedSettings;
if (ImGui::Begin("Shader Compilation Status", nullptr, kFlags)) {
ImGui::SetWindowFontScale(0.85f);
ImGui::Text("%u shader%s compiling", queuedPipelines, queuedPipelines == 1 ? "" : "s");
}
ImGui::End();
ImGui::PopStyleVar();
}
void DrawStartupScreen() {
if (!StartupScreenVisible()) {
return;
}
const ImGuiViewport* viewport = ImGui::GetMainViewport();
ImGui::SetNextWindowPos(viewport->Pos, ImGuiCond_Always);
ImGui::SetNextWindowSize(viewport->Size, ImGuiCond_Always);
ImGui::SetNextWindowBgAlpha(1.0f);
ImGui::PushStyleColor(ImGuiCol_WindowBg, IM_COL32(0, 0, 0, 255));
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.0f, 0.0f));
constexpr ImGuiWindowFlags kFlags = ImGuiWindowFlags_NoDecoration |
ImGuiWindowFlags_NoFocusOnAppearing |
ImGuiWindowFlags_NoInputs |
ImGuiWindowFlags_NoMove |
ImGuiWindowFlags_NoNav |
ImGuiWindowFlags_NoSavedSettings |
ImGuiWindowFlags_NoBringToFrontOnFocus;
if (ImGui::Begin("Wiicompiled Startup", nullptr, kFlags)) {
ImGui::SetWindowFontScale(1.25f);
constexpr const char* kTitle = "WiiCompiled";
const ImVec2 titleSize = ImGui::CalcTextSize(kTitle);
const float titleX = std::max(0.0f, (viewport->Size.x - titleSize.x) * 0.5f);
const float startY = std::max(0.0f, (viewport->Size.y - titleSize.y) * 0.5f);
ImGui::SetCursorPos(ImVec2(titleX, startY));
ImGui::TextUnformatted(kTitle);
}
ImGui::End();
ImGui::PopStyleVar();
ImGui::PopStyleColor();
}
void DrawExitPrompt() {
constexpr const char* kTitle = "Exit";
if (g_exitPromptOpen && !ImGui::IsPopupOpen(kTitle)) ImGui::OpenPopup(kTitle);
if (!ImGui::BeginPopupModal(kTitle, &g_exitPromptOpen, ImGuiWindowFlags_AlwaysAutoResize)) return;
ImGui::TextUnformatted("Quit the game?");
if (ImGui::Button("Exit", ImVec2(120.0f, 0.0f))) ExitForAuroraWindowClose();
ImGui::SameLine();
if (ImGui::Button("Cancel", ImVec2(120.0f, 0.0f))) g_exitPromptOpen = false;
if (!g_exitPromptOpen) ImGui::CloseCurrentPopup();
ImGui::EndPopup();
}
void DrawResolutionMenu() {
const auto resolutionIt = std::find_if(kResolutions.begin(), kResolutions.end(), [](const ResolutionItem& item) {
return std::fabs(item.scale - g_resolutionScale) < 0.001f;
});
const char* resolutionLabel = resolutionIt != kResolutions.end() ? resolutionIt->label : "Custom";
const std::string resolutionMenuLabel = std::string("Resolution: ") + resolutionLabel;
if (ImGui::BeginMenu(resolutionMenuLabel.c_str())) {
for (const auto& resolution : kResolutions) {
const bool selected = std::fabs(resolution.scale - g_resolutionScale) < 0.001f;
const bool disabled = IsHighFrameRateMode() && IsHighResolutionScale(resolution.scale);
ImGui::BeginDisabled(disabled);
const bool clicked = ImGui::MenuItem(resolution.label, nullptr, selected);
ImGui::EndDisabled();
if (clicked) {
SetResolutionScale(resolution.scale);
}
}
ImGui::EndMenu();
}
}
void DrawTopBar() {
if (!g_topBarVisible) {
return;
}
const ImGuiViewport* viewport = ImGui::GetMainViewport();
ImGui::GetBackgroundDrawList()->AddRectFilled(viewport->Pos,
ImVec2(viewport->Pos.x + viewport->Size.x, viewport->Pos.y + viewport->Size.y),
IM_COL32(0, 0, 0, 70));
ImGui::SetNextWindowPos(ImVec2(viewport->Pos.x + viewport->Size.x * 0.5f,
viewport->Pos.y + viewport->Size.y - 24.0f),
ImGuiCond_Always, ImVec2(0.5f, 1.0f));
ImGui::SetNextWindowBgAlpha(0.85f);
if (ImGui::Begin("Settings input hint", nullptr,
ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_AlwaysAutoResize |
ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoSavedSettings |
ImGuiWindowFlags_NoFocusOnAppearing)) {
ImGui::TextUnformatted("Settings open - game controls disabled. Press F10 to return to the game.");
}
ImGui::End();
if (!ImGui::BeginMainMenuBar()) return;
ImGui::TextUnformatted("WiiCompiled");
ImGui::Separator();
DrawResolutionMenu();
if (ImGui::BeginMenu("Graphics")) {
DrawGraphicsSettings();
ImGui::EndMenu();
}
if (ImGui::BeginMenu("VR")) {
DrawVrSettings();
ImGui::EndMenu();
}
if (ImGui::BeginMenu("Camera")) {
DrawVrCameraSettings();
ImGui::EndMenu();
}
if (ImGui::BeginMenu("Controller settings")) {
DrawControllerSettings();
// Nest capture under this menu so opening/closing the modal preserves
// the settings popup and its current port and scroll position.
DrawRebindPrompt();
ImGui::EndMenu();
}
const std::string audioLabel = g_audioMuted
? "Audio: Muted"
: "Audio: " + std::to_string(g_audioVolumePercent) + "%";
// Keep the popup ID stable while the Master slider changes the visible
// label. Without the ### suffix, ImGui treats every new percentage as a
// different menu and closes the popup on the first drag update.
const std::string audioMenuLabel = audioLabel + "###AudioSettingsMenu";
if (ImGui::BeginMenu(audioMenuLabel.c_str())) {
DrawAudioSettings();
DrawRebindPrompt();
ImGui::EndMenu();
}
if (ImGui::BeginMenu("Diagnostics")) {
DrawDiagnosticsSettings();
ImGui::EndMenu();
}
if (update_check::Relevant(update_check::Current()) && ImGui::BeginMenu("Updates")) {
DrawUpdateSettings();
ImGui::EndMenu();
}
const ImGuiStyle& style = ImGui::GetStyle();
const float hideWidth = ImGui::CalcTextSize("Hide (F10)").x + style.FramePadding.x * 2.0f;
const float exitWidth = ImGui::CalcTextSize("X").x + style.FramePadding.x * 2.0f;
ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(),
ImGui::GetWindowWidth() - hideWidth - exitWidth - style.ItemSpacing.x - 8.0f));
if (ImGui::MenuItem("Hide (F10)")) {
SetTopBarVisible(false);
}
if (ImGui::MenuItem("X")) {
g_exitPromptOpen = true;
}
ImGui::EndMainMenuBar();
}
bool IsToggleKey(const SDL_Event& event, SDL_Scancode code) {
return event.type == SDL_EVENT_KEY_DOWN && !event.key.repeat && event.key.scancode == code;
}
// Consumes the next key press into the recenter binding while the menu
// button is armed. Esc leaves the existing binding alone, Backspace clears
// it; anything else becomes the new hotkey.
bool CaptureVrRecenterBinding(const SDL_Event& event) {
if (!g_vrRecenterRebinding || event.type != SDL_EVENT_KEY_DOWN || event.key.repeat) {
return false;
}
g_vrRecenterRebinding = false;
if (event.key.scancode == SDL_SCANCODE_ESCAPE) {
return true;
}
g_vrRecenterScancode = event.key.scancode == SDL_SCANCODE_BACKSPACE
? SDL_SCANCODE_UNKNOWN
: event.key.scancode;
const char* name = g_vrRecenterScancode == SDL_SCANCODE_UNKNOWN
? nullptr
: SDL_GetScancodeName(g_vrRecenterScancode);
RuntimeConfigFile::SetVrRecenterKey(name ? name : "");
return true;
}
bool IsMouseActivity(const SDL_Event& event) {
switch (event.type) {
case SDL_EVENT_MOUSE_MOTION:
case SDL_EVENT_MOUSE_BUTTON_DOWN:
case SDL_EVENT_MOUSE_BUTTON_UP:
case SDL_EVENT_MOUSE_WHEEL:
return true;
default:
return false;
}
}
// Runs on the thread that pumps SDL events (the same one that calls Draw), so
// the SDL cursor calls are safe here.
void UpdateCursorAutoHide() {
#if defined(__ANDROID__)
// No pointer icon to manage on a headset, and SDL changes it through Java,
// which ART aborts on from a guest fiber's stack: Draw runs on whichever
// guest thread advances the retrace. InitializeRuntimeSettings keeps
// ImGui's SDL backend off the cursor for the same reason.
#else
const bool shouldHide =
!g_topBarVisible && Clock::now() - g_lastMouseActivity >= kCursorAutoHideDelay;
if (shouldHide == g_cursorHidden) {
return;
}
g_cursorHidden = shouldHide;
// ImGui_ImplSDL3_NewFrame calls SDL_ShowCursor every frame unless this flag is set.
if (shouldHide) {
ImGui::GetIO().ConfigFlags |= ImGuiConfigFlags_NoMouseCursorChange;
SDL_HideCursor();
} else {
ImGui::GetIO().ConfigFlags &= ~ImGuiConfigFlags_NoMouseCursorChange;
SDL_ShowCursor();
}
#endif
}
// Alt+Enter toggles the display mode inside aurora without going through the
// F10 combo, so the active mode is compared against the last persisted one
// every frame and written back on change.
void PersistDisplayModeIfChanged() {
const int active = static_cast<int>(aurora_get_display_mode());
if (active == g_displayMode) {
return;
}
g_displayMode = active;
RuntimeConfigFile::SetDisplayMode(std::string(kDisplayModeConfigNames[static_cast<size_t>(active)]));
}
// The headset's settings panel (vr/openxr_settings_panel.h): the same menus as
// the F10 bar, drawn with an ImGui context of its own at a size fixed in panel
// pixels, operated by the VR controllers' pointer and handed to Aurora, which
// lays it over the eyes. The desktop context is untouched, so the F10 bar and
// the panel can both be open.
struct VrSettingsPanel {
ImGuiContext* context = nullptr;
Clock::time_point lastFrame{};
bool selectHeld = false;
};
VrSettingsPanel g_vrSettingsPanel;
ImGuiContext* CreateVrSettingsPanelContext() {
ImGuiContext* const desktop = ImGui::GetCurrentContext();
const ImGuiIO& desktopIo = ImGui::GetIO();
ImGuiContext* const context = ImGui::CreateContext();
ImGui::SetCurrentContext(context);
ImGuiIO& io = ImGui::GetIO();
io.IniFilename = nullptr;
io.LogFilename = nullptr;
// ImGui's current context is one process-wide pointer, and Aurora's frame
// worker renders draw data (the desktop overlay, this panel) while this
// thread may have switched to this context to draw the panel. The WebGPU
// backend finds its device objects through the current context's IO, so
// this context carries the desktop's: without them, a render on the worker
// during the switch reads through a null backend.
io.BackendRendererUserData = desktopIo.BackendRendererUserData;
io.BackendRendererName = desktopIo.BackendRendererName;
// No platform backend draws a cursor for it, and nothing else shows where
// the controller is aiming.
io.MouseDrawCursor = true;
// Aurora's WebGPU backend, which renders this context's draw data, honours it.
io.BackendFlags |= ImGuiBackendFlags_RendererHasVtxOffset;
// The default font rasterised at the panel's scale rather than magnified,
// with an atlas of its own so the desktop font texture is left alone.
ImFontConfig font;
font.SizePixels = 13.0f * mkw::vr::kSettingsPanelUiScale;
io.Fonts->AddFontDefault(&font);
unsigned char* pixels = nullptr;
int width = 0;
int height = 0;
io.Fonts->GetTexDataAsRGBA32(&pixels, &width, &height);
io.Fonts->SetTexID(aurora_imgui_add_texture(static_cast<uint32_t>(width), static_cast<uint32_t>(height), pixels));
io.Fonts->ClearTexData();
ImGui::StyleColorsDark();
ImGuiStyle& style = ImGui::GetStyle();
style.ScaleAllSizes(mkw::vr::kSettingsPanelUiScale);
// Nothing behind the panel is meant to be read through it.
style.Colors[ImGuiCol_WindowBg].w = 0.97f;
style.Colors[ImGuiCol_PopupBg].w = 0.98f;
ImGui::SetCurrentContext(desktop);
return context;
}
void DrawVrSettingsPanelWindow() {
const ImGuiViewport* viewport = ImGui::GetMainViewport();
ImGui::SetNextWindowPos(viewport->Pos, ImGuiCond_Always);
ImGui::SetNextWindowSize(viewport->Size, ImGuiCond_Always);
constexpr ImGuiWindowFlags kFlags = ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove |
ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoBringToFrontOnFocus;
if (ImGui::Begin("WiiCompiled settings", nullptr, kFlags)) {
ImGui::TextUnformatted("WiiCompiled settings");
const ImGuiStyle& style = ImGui::GetStyle();
const float recenterWidth = ImGui::CalcTextSize("Recenter view").x + style.FramePadding.x * 2.0f;
const float closeWidth = ImGui::CalcTextSize("Close").x + style.FramePadding.x * 2.0f;
ImGui::SameLine(ImGui::GetContentRegionMax().x - recenterWidth - closeWidth - style.ItemSpacing.x);
if (ImGui::Button("Recenter view")) {
mkw::vr::OpenXRRequestRecenter();
}
ImGui::SameLine();
if (ImGui::Button("Close")) {
mkw::vr::OpenXRSetSettingsPanelOpen(false);
}
ImGui::TextDisabled("Aim and pull a trigger to change a setting, push a thumbstick to scroll.");
ImGui::TextDisabled("%s to close. The game does not see the controllers meanwhile.",
mkw::vr::OpenXRGetControllerMode() == mkw::vr::OpenXRControllerMode::Gamepad
? "Click both thumbsticks or press Menu"
: "Press left Y or Menu");
ImGui::Separator();
if (ImGui::BeginTabBar("Settings")) {
const auto tab = [](const char* label, void (*draw)()) {
if (ImGui::BeginTabItem(label)) {
// Its own scrolling region, so the header stays in view.
ImGui::BeginChild("Contents");
draw();
ImGui::EndChild();
ImGui::EndTabItem();
}
};
tab("VR", DrawVrSettings);
tab("Camera", DrawVrCameraSettings);
tab("Graphics", [] {
DrawResolutionMenu();
ImGui::Separator();
DrawGraphicsSettings();
});
tab("Controllers", DrawControllerSettings);
tab("Audio", DrawAudioSettings);
tab("Diagnostics", DrawDiagnosticsSettings);
if (update_check::Relevant(update_check::Current())) {
tab("Updates", DrawUpdateSettings);
}
ImGui::EndTabBar();
}
}
ImGui::End();
}
// Called once per presented frame after the desktop menus, with the frame
// worker done: the draw data handed to Aurora must stay put until the next
// frame is encoded, and only the next call here rebuilds it.
void DrawVrSettingsPanel() {
if (!mkw::vr::OpenXRIsRunning() || !mkw::vr::OpenXRSettingsPanelOpen()) {
aurora_imgui_set_stereo_overlay(nullptr, 0.0f);
return;
}
VrSettingsPanel& panel = g_vrSettingsPanel;
if (panel.context == nullptr) {
panel.context = CreateVrSettingsPanelContext();
}
const mkw::vr::OpenXRSettingsPanelPointer pointer = mkw::vr::OpenXRTakeSettingsPanelPointer();
const Clock::time_point now = Clock::now();
float deltaSeconds = 1.0f / 60.0f;
if (panel.lastFrame != Clock::time_point{}) {
deltaSeconds = std::clamp(std::chrono::duration<float>(now - panel.lastFrame).count(), 1.0e-4f, 0.25f);
}
panel.lastFrame = now;
ImGuiContext* const desktop = ImGui::GetCurrentContext();
ImGui::SetCurrentContext(panel.context);
ImGuiIO& io = ImGui::GetIO();
io.DisplaySize = ImVec2(mkw::vr::kSettingsPanelWidthPixels, mkw::vr::kSettingsPanelHeightPixels);
io.DeltaTime = deltaSeconds;
if (pointer.valid) {
io.AddMousePosEvent(pointer.x, pointer.y);
} else {
io.AddMousePosEvent(-FLT_MAX, -FLT_MAX);
}
if (pointer.select != panel.selectHeld) {
io.AddMouseButtonEvent(ImGuiMouseButton_Left, pointer.select);
panel.selectHeld = pointer.select;
}
if (pointer.wheel != 0.0f) {
io.AddMouseWheelEvent(0.0f, pointer.wheel);
}
ImGui::NewFrame();
DrawVrSettingsPanelWindow();
ImGui::Render();
ImDrawData* const drawData = ImGui::GetDrawData();
ImGui::SetCurrentContext(desktop);
aurora_imgui_set_stereo_overlay(drawData, mkw::vr::kSettingsPanelWidthFraction);
}
} // namespace
void InitializeRuntimeSettings() noexcept {
#if defined(__ANDROID__)
// ImGui_ImplSDL3_NewFrame would otherwise call SDL_SetCursor/SDL_HideCursor
// (Java on Android) from the guest fiber that starts the next host frame.
ImGui::GetIO().ConfigFlags |= ImGuiConfigFlags_NoMouseCursorChange;
#endif
PAD_HLE_SetRumbleEnabled(g_rumbleEnabled);
InputBindings::Reload();
controller_mapping_wizard::LoadPersistedMappings();
ApplyConfiguredMappings();
AudioBackend::Instance().SetMasterVolume(static_cast<float>(g_audioVolumePercent) / 100.0f);
AudioBackend::Instance().SetMuted(g_audioMuted);
MusicAttenuation::SetMusicVolume(static_cast<float>(g_musicVolumePercent) / 100.0f);
MusicAttenuation::SetSoundEffectsVolume(static_cast<float>(g_soundEffectsVolumePercent) / 100.0f);
MusicAttenuation::SetUiVolume(static_cast<float>(g_uiVolumePercent) / 100.0f);
MusicAttenuation::SetVoicesVolume(static_cast<float>(g_voicesVolumePercent) / 100.0f);
MusicAttenuation::SetEnabled(g_attenuateMusicWhenMediaPlays);
RuntimeGameGraphicsOptions::SetDisabledPostProcessingPaths(g_disabledPostProcessingPaths);
const uint32_t targetFps = kFrameInterpolationTargetFps[static_cast<size_t>(g_frameInterpolationMode)];
LimitResolutionForFrameRate();
aurora_set_frame_interpolation_fps(targetFps);
aurora_set_display_mode(static_cast<AuroraDisplayMode>(g_displayMode));
g_displayMode = static_cast<int>(aurora_get_display_mode());
aurora_set_disable_copy_filter(g_disableCopyFilter);
#if defined(MKW_VR_STANDALONE)
aurora_set_stereo_foveation(static_cast<uint32_t>(g_vrFoveation));
#endif
aurora_set_stereo_mirror_view(static_cast<AuroraStereoMirrorView>(g_vrMirrorView));
mkw::vr::OpenXRSetControllerMode(static_cast<mkw::vr::OpenXRControllerMode>(g_vrControllerMode));
ApplyVrHudVirtualScreen();
aurora_set_skip_unready_pipelines(g_skipUnreadyPipelines);
mkw::vr::MkwVRFirstPersonApplyConfiguredSettings();
mkw::vr::diagnostics::SetEnabled(g_openxrDiagnosticsLogging);
g_strapInputAccepted.store(false, std::memory_order_relaxed);
g_startupDismissFrame.store(UINT64_MAX, std::memory_order_relaxed);
PADBlockInput(false);
InputBindings::SetInputBlocked(false);
}
void RefreshVrHudVirtualScreen() noexcept { ApplyVrHudVirtualScreen(); }
void RequestFirstPersonToggle() noexcept { g_firstPersonToggleRequested.store(true, std::memory_order_release); }
void HandleEvents(const AuroraEvent* events) noexcept {
if (!events) {
return;
}
for (const AuroraEvent* ev = events; ev->type != AURORA_NONE; ++ev) {
if (ev->type == AURORA_CONTROLLER_ADDED || ev->type == AURORA_CONTROLLER_REMOVED) {
g_configuredControllerIndices.fill(std::numeric_limits<int32_t>::min());
}
if (ev->type != AURORA_SDL_EVENT) {
continue;
}
controller_mapping_wizard::HandleSdlEvent(ev->sdl);
if (CaptureVrRecenterBinding(ev->sdl)) {
continue;
}
if (g_rebind.active && (IsToggleKey(ev->sdl, SDL_SCANCODE_BACKSPACE) ||
IsToggleKey(ev->sdl, SDL_SCANCODE_DELETE))) {
CompleteRebind(g_rebind.kind == RebindKind::Controller ? PAD_NATIVE_BUTTON_DISABLED
: static_cast<uint32_t>(PAD_KEY_INVALID));
}
if (ev->sdl.type == SDL_EVENT_GAMEPAD_BUTTON_DOWN || ev->sdl.type == SDL_EVENT_GAMEPAD_BUTTON_UP) {
HandleGamepadFirstPersonClick(ev->sdl.gbutton);
}
if (!g_rebind.active && IsToggleKey(ev->sdl, SDL_SCANCODE_F10)) {
SetTopBarVisible(!g_topBarVisible);
}
if (!g_rebind.active && g_vrRecenterScancode != SDL_SCANCODE_UNKNOWN &&
IsToggleKey(ev->sdl, g_vrRecenterScancode)) {
mkw::vr::OpenXRRequestRecenter();
}
if (!g_rebind.active && g_muteHotkey != PAD_KEY_INVALID &&
IsToggleKey(ev->sdl, static_cast<SDL_Scancode>(g_muteHotkey))) {
g_audioMuted = !g_audioMuted;
AudioBackend::Instance().SetMuted(g_audioMuted);
RuntimeConfigFile::SetAudioMuted(g_audioMuted);
}
if (!g_rebind.active && !g_topBarVisible && IsToggleKey(ev->sdl, SDL_SCANCODE_ESCAPE)) {
g_exitPromptOpen = true;
}
if (IsMouseActivity(ev->sdl)) {
g_lastMouseActivity = Clock::now();
}
}
}
void ReleaseControllers() noexcept {
// Aurora drives the LED white on first PADRead and never clears it, and the
// exit paths terminate the process outright, so do it here.
bool queued = false;
for (uint32_t port = 0; port < PAD_MAX_CONTROLLERS; ++port) {
const s32 index = PADGetIndexForPort(port);
if (index < 0) continue;
if (SDL_Gamepad* pad = PADGetSDLGamepadForIndex(static_cast<u32>(index))) {
SDL_SetGamepadLED(pad, 0, 0, 0);
queued = true;
}
}
constexpr std::array<uint32_t, PAD_MAX_CONTROLLERS> stopAll{
PAD_MOTOR_STOP_HARD, PAD_MOTOR_STOP_HARD, PAD_MOTOR_STOP_HARD, PAD_MOTOR_STOP_HARD};
PADControlAllMotors(stopAll.data());
// SDL hands LED and rumble reports to its own HIDAPI sender thread rather
// than writing them here, so without this the process dies before the
// controller ever receives them.
if (queued) SDL_Delay(120);
}
void Draw() noexcept {
// The overlay draws into the game thread's own ImGui frame
// (aurora_imgui_host_frame_begin); the worker replays a copy of the draw
// data, so there is nothing to wait for here.
// Explain fallback without interrupting gameplay or capturing input.
static std::string shownXrError;
static double xrNoticeUntil = 0.0;
const auto xrError = mkw::vr::OpenXRLastError();
if (!xrError.empty() && xrError != shownXrError) {
shownXrError = xrError;
xrNoticeUntil = ImGui::GetTime() + 15.0;
}
if (!shownXrError.empty() && ImGui::GetTime() < xrNoticeUntil) {
ImGui::SetNextWindowPos(ImVec2(16.0f, 60.0f), ImGuiCond_Always);
ImGui::SetNextWindowBgAlpha(0.9f);
if (ImGui::Begin("OpenXR desktop fallback", nullptr,
ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_AlwaysAutoResize |
ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoSavedSettings |
ImGuiWindowFlags_NoFocusOnAppearing)) {
ImGui::PushTextWrapPos(440.0f);
ImGui::TextUnformatted("OpenXR unavailable - playing on the desktop.");
ImGui::TextUnformatted(shownXrError.c_str());
ImGui::TextUnformatted("Check your headset and active OpenXR runtime, then restart. Details: F10 > VR.");
ImGui::PopTextWrapPos();
}
ImGui::End();
}
// Also drive the Wii Remote rescan from here: PADRead runs it too, but this
// runs once per presented frame whatever the game is doing (e.g. sitting in
// its "communications interrupted" prompt without polling pads). Same guest
// thread as PADRead, so no concurrent access to the scanner's state.
WiiRemoteInput::Poll();
// Likewise for the VR controllers' gamepad, so it keeps being written even
// while the game is not reading pads.
mkw::vr::OpenXRApplyControllerState();
if (g_firstPersonToggleRequested.exchange(false, std::memory_order_acq_rel)) {
ToggleFirstPersonCamera();
}
ApplyConfiguredMappings();
PersistDisplayModeIfChanged();
UpdateCursorAutoHide();
if (!StartupScreenVisible()) {
DrawShaderCompilationStatus();
}
DrawFpsOverlay();
DrawTopBar();
DrawExitPrompt();
DrawUpdatePrompt();
controller_mapping_wizard::Draw();
// The wizard captures raw presses; keep them out of the game.
const bool inputBlocked = controller_mapping_wizard::IsActive() || g_rebind.active;
PADBlockInput(inputBlocked);
InputBindings::SetInputBlocked(inputBlocked);
DrawStartupScreen();
DrawVrSettingsPanel();
}
bool StartupScreenVisible() noexcept {
return !g_strapInputAccepted.load(std::memory_order_acquire) ||
g_presentedFrame < g_startupDismissFrame.load(std::memory_order_relaxed);
}
void NotifyStrapInputAccepted() noexcept {
bool expected = false;
if (g_strapInputAccepted.compare_exchange_strong(expected, true, std::memory_order_acq_rel)) {
g_startupDismissFrame.store(g_presentedFrame + kStrapTransitionCoverFrames,
std::memory_order_release);
}
}
void AdvancePresentedFrame() noexcept { ++g_presentedFrame; }
} // namespace settings_overlay