From 0dc073ca48476a4936036d75e2aceecbe19f0bc0 Mon Sep 17 00:00:00 2001 From: lucas lelievre Date: Wed, 23 Apr 2025 14:36:29 +0200 Subject: [PATCH] VRChat Config Warnings (#1358) Co-authored-by: Erimel --- gui/public/i18n/en/translation.ftl | 45 +++ gui/src/App.tsx | 9 + gui/src/components/commons/TipBox.tsx | 2 +- gui/src/components/commons/icon/CheckIcon.tsx | 6 +- gui/src/components/commons/icon/CrossIcon.tsx | 9 +- gui/src/components/home/Home.tsx | 24 +- gui/src/components/vrc/VRCWarningsPage.tsx | 301 ++++++++++++++++++ gui/src/hooks/firmware-tool.ts | 1 + gui/src/hooks/vrc-config.ts | 70 ++++ .../src/main/java/dev/slimevr/VRServer.kt | 7 + .../slimevr/games/vrchat/VRCConfigHandler.kt | 179 +++++++++++ .../dev/slimevr/protocol/rpc/RPCBuilder.java | 28 ++ .../dev/slimevr/protocol/rpc/RPCHandler.kt | 2 + .../rpc/games/vrchat/RPCVRCBuilder.kt | 81 +++++ .../rpc/games/vrchat/RPCVRChatHandler.kt | 77 +++++ .../tracking/processor/HumanPoseManager.kt | 5 + .../src/main/java/dev/slimevr/desktop/Main.kt | 2 + .../games/vrchat/DesktopVRCConfigHandler.kt | 126 ++++++++ solarxr-protocol | 2 +- 19 files changed, 965 insertions(+), 11 deletions(-) create mode 100644 gui/src/components/vrc/VRCWarningsPage.tsx create mode 100644 gui/src/hooks/vrc-config.ts create mode 100644 server/core/src/main/java/dev/slimevr/games/vrchat/VRCConfigHandler.kt create mode 100644 server/core/src/main/java/dev/slimevr/protocol/rpc/RPCBuilder.java create mode 100644 server/core/src/main/java/dev/slimevr/protocol/rpc/games/vrchat/RPCVRCBuilder.kt create mode 100644 server/core/src/main/java/dev/slimevr/protocol/rpc/games/vrchat/RPCVRChatHandler.kt create mode 100644 server/desktop/src/main/java/dev/slimevr/desktop/games/vrchat/DesktopVRCConfigHandler.kt diff --git a/gui/public/i18n/en/translation.ftl b/gui/public/i18n/en/translation.ftl index b79f3e8ba..8e1856dc9 100644 --- a/gui/public/i18n/en/translation.ftl +++ b/gui/public/i18n/en/translation.ftl @@ -629,6 +629,9 @@ settings-general-gesture_control-numberTrackersOverThreshold-description = Incre ## Appearance settings settings-interface-appearance = Appearance +settings-general-interface-dev_mode = Developer Mode +settings-general-interface-dev_mode-description = This mode can be useful if you need in-depth data or to interact with connected trackers on a more advanced level. +settings-general-interface-dev_mode-label = Developer Mode settings-general-interface-theme = Color theme settings-general-interface-show-navbar-onboarding = Show "{ navbar-onboarding }" on navigation bar settings-general-interface-show-navbar-onboarding-description = This changes if the "{ navbar-onboarding }" button shows on the navigation bar. @@ -1411,6 +1414,48 @@ unknown_device-modal-description = There is a new tracker with MAC address {$ unknown_device-modal-confirm = Sure! unknown_device-modal-forget = Ignore it + +# VRChat config warnings +vrc_config-page-title = VRChat configuration warnings +vrc_config-page-desc = This page shows the state of your VRChat settings and shows what settings are incompatible with SlimeVR. It is highly recommended that you fix any warnings showing up here for the best user experience with SlimeVR. +vrc_config-page-help = Can't find the settings? +vrc_config-page-help-desc = Check out our documentation on this topic! +vrc_config-page-big_menu = Tracking & IK (Big Menu) +vrc_config-page-big_menu-desc = Settings related to IK in the big settings menu +vrc_config-page-wrist_menu = Tracking & IK (Wrist Menu) +vrc_config-page-wrist_menu-desc = Settings related to IK in small settings menu (wrist menu) +vrc_config-on = On +vrc_config-off = Off +vrc_config-invalid = You have misconfigured VRChat settings! +vrc_config-show_more = Show more +vrc_config-setting_name = VRChat Setting name +vrc_config-recommended_value = Recommended Value +vrc_config-current_value = Current Value +vrc_config-legacy_mode = Use Legacy IK Solving +vrc_config-disable_shoulder_tracking = Disable Shoulder Tracking +vrc_config-shoulder_width_compensation = Shoulder Width Compensation +vrc_config-spine_mode = FBT Spine Mode +vrc_config-tracker_model = FBT Tracker Model +vrc_config-avatar_measurement_type = Avatar Measurement +vrc_config-calibration_range = Calibration Range +vrc_config-calibration_visuals = Display Calibration Visuals +vrc_config-user_height = User Real Height + +vrc_config-spine_mode-UNKNOWN = Unknown +vrc_config-spine_mode-LOCK_BOTH = Lock Both +vrc_config-spine_mode-LOCK_HEAD = Lock Head +vrc_config-spine_mode-LOCK_HIP = Lock Hip + +vrc_config-tracker_model-UNKNOWN = Unkown +vrc_config-tracker_model-AXIS = Axis +vrc_config-tracker_model-BOX = Box +vrc_config-tracker_model-SPHERE = Sphere +vrc_config-tracker_model-SYSTEM = System + +vrc_config-avatar_measurement_type-UNKNOWN = Unknown +vrc_config-avatar_measurement_type-HEIGHT = Height +vrc_config-avatar_measurement_type-ARM_SPAN = Arm Span + ## Error collection consent modal error_collection_modal-title = Can we collect errors? error_collection_modal-description_v2 = { settings-interface-behavior-error_tracking-description_v2 } diff --git a/gui/src/App.tsx b/gui/src/App.tsx index 560d31507..de9beabac 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -59,6 +59,7 @@ import { ScaledProportionsPage } from './components/onboarding/pages/body-propor import { AdvancedSettings } from './components/settings/pages/AdvancedSettings'; import { FirmwareUpdate } from './components/firmware-update/FirmwareUpdate'; import { ConnectionLost } from './components/onboarding/pages/ConnectionLost'; +import { VRCWarningsPage } from './components/vrc/VRCWarningsPage'; export const GH_REPO = 'SlimeVR/SlimeVR-Server'; export const VersionContext = createContext(''); @@ -110,6 +111,14 @@ function Layout() { } /> + + + + } + /> -
+
diff --git a/gui/src/components/commons/icon/CrossIcon.tsx b/gui/src/components/commons/icon/CrossIcon.tsx index 1c4f94a55..4980d685c 100644 --- a/gui/src/components/commons/icon/CrossIcon.tsx +++ b/gui/src/components/commons/icon/CrossIcon.tsx @@ -1,6 +1,11 @@ -export function CrossIcon() { +export function CrossIcon({ size = 20 }: { size: number }) { return ( - + { @@ -51,9 +53,7 @@ export function Home() {
status.prioritized) - .length === 0 && 'hidden' + 'px-3 pt-3 gap-3 w-full grid md:grid-cols-2 mobile:grid-cols-1' )} > {filteredStatuses @@ -69,6 +69,22 @@ export function Home() { ))} + {invalidConfig && ( + +
+
+ +
+
+ +
+ +
+ +
+
+
+ )}
{trackers.length === 0 && ( diff --git a/gui/src/components/vrc/VRCWarningsPage.tsx b/gui/src/components/vrc/VRCWarningsPage.tsx new file mode 100644 index 000000000..25670fa62 --- /dev/null +++ b/gui/src/components/vrc/VRCWarningsPage.tsx @@ -0,0 +1,301 @@ +import { Typography } from '@/components/commons/Typography'; +import { ReactNode } from 'react'; +import {} from 'solarxr-protocol'; +import { CheckIcon } from '@/components/commons/icon/CheckIcon'; +import { WarningIcon } from '@/components/commons/icon/WarningIcon'; +import { + avatarMeasurementTypeTranslationMap, + spineModeTranslationMap, + trackerModelTranslationMap, + useVRCConfig, +} from '@/hooks/vrc-config'; +import { Localized, useLocalization } from '@fluent/react'; +import classNames from 'classnames'; +import { useLocaleConfig } from '@/i18n/config'; +import { A } from '@/components/commons/A'; + +export function SettingRow({ + name, + valid, + value, + recommendedValue, +}: { + valid: boolean; + name: string; + recommendedValue: ReactNode; + value: ReactNode; +}) { + return ( + + + {valid ? ( + + ) : ( + + )} + + {name} + + + {recommendedValue} + + {value} + + + ); +} + +const onOffKey = (value: boolean) => + value ? 'vrc_config-on' : 'vrc_config-off'; + +export function VRCWarningsPage() { + const { l10n } = useLocalization(); + const { state } = useVRCConfig(); + const { currentLocales } = useLocaleConfig(); + + const meterFormat = Intl.NumberFormat(currentLocales, { + style: 'unit', + unit: 'meter', + maximumFractionDigits: 2, + }); + + if (!state || !state.isSupported) { + return <>; + } + + return ( +
+
+ + + + + + +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + } + value={ + + } + valid={state.validity.legacyModeOk} + > + + } + value={ + + } + valid={state.validity.shoulderTrackingOk} + > + + } + value={ + + } + valid={state.validity.shoulderWidthCompensationOk} + > + + } + value={ + + } + valid={state.validity.calibrationVisualsOk} + > + + + } + value={ + + } + valid={state.validity.trackerModelOk} + > + +
+ + + + + + + + + + + +
+
+
+ + + + + + + + + + + + + + + + + + l10n.getString(spineModeTranslationMap[mode]) + ) + .join(', ')} + value={ + + } + valid={state.validity.spineModeOk} + > + + + } + value={ + + } + valid={state.validity.avatarMeasurementTypeOk} + > + +
+ + + + + + + + + + + +
+
+
+
+
+
+
+ + + + , + }} + > + + +
+
+ ); +} diff --git a/gui/src/hooks/firmware-tool.ts b/gui/src/hooks/firmware-tool.ts index 665f88a71..54e157213 100644 --- a/gui/src/hooks/firmware-tool.ts +++ b/gui/src/hooks/firmware-tool.ts @@ -64,6 +64,7 @@ export const boardTypeToFirmwareToolBoardType: Record< [BoardType.XIAO_ESP32C3]: null, [BoardType.ESP32C6DEVKITC1]: null, [BoardType.GLOVE_IMU_SLIMEVR_DEV]: null, + [BoardType.GESTURES]: null, }; export const firmwareToolToBoardType: Record = diff --git a/gui/src/hooks/vrc-config.ts b/gui/src/hooks/vrc-config.ts new file mode 100644 index 000000000..076929d8d --- /dev/null +++ b/gui/src/hooks/vrc-config.ts @@ -0,0 +1,70 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useWebsocketAPI } from './websocket-api'; +import { + RpcMessage, + VRCAvatarMeasurementType, + VRCConfigStateChangeResponseT, + VRCConfigStateRequestT, + VRCSpineMode, + VRCTrackerModel, +} from 'solarxr-protocol'; + +type NonNull = { + [P in keyof T]: NonNullable; +}; + +export type VRCConfigState = + | { isSupported: false } + | ({ isSupported: true } & NonNull< + Pick + >); + +export const spineModeTranslationMap: Record = { + [VRCSpineMode.UNKNOWN]: 'vrc_config-spine_mode-UNKNOWN', + [VRCSpineMode.LOCK_BOTH]: 'vrc_config-spine_mode-LOCK_BOTH', + [VRCSpineMode.LOCK_HEAD]: 'vrc_config-spine_mode-LOCK_HEAD', + [VRCSpineMode.LOCK_HIP]: 'vrc_config-spine_mode-LOCK_HIP', +}; + +export const trackerModelTranslationMap: Record = { + [VRCTrackerModel.UNKNOWN]: 'vrc_config-tracker_model-UNKNOWN', + [VRCTrackerModel.AXIS]: 'vrc_config-tracker_model-AXIS', + [VRCTrackerModel.BOX]: 'vrc_config-tracker_model-BOX', + [VRCTrackerModel.SPHERE]: 'vrc_config-tracker_model-SPHERE', + [VRCTrackerModel.SYSTEM]: 'vrc_config-tracker_model-SYSTEM', +}; + +export const avatarMeasurementTypeTranslationMap: Record< + VRCAvatarMeasurementType, + string +> = { + [VRCAvatarMeasurementType.UNKNOWN]: 'vrc_config-avatar_measurement_type-UNKNOWN', + [VRCAvatarMeasurementType.HEIGHT]: 'vrc_config-avatar_measurement_type-HEIGHT', + [VRCAvatarMeasurementType.ARM_SPAN]: 'vrc_config-avatar_measurement_type-ARM_SPAN', +}; + +export function useVRCConfig() { + const { sendRPCPacket, useRPCPacket } = useWebsocketAPI(); + const [state, setState] = useState(null); + + useEffect(() => { + sendRPCPacket(RpcMessage.VRCConfigStateRequest, new VRCConfigStateRequestT()); + }, []); + + useRPCPacket( + RpcMessage.VRCConfigStateChangeResponse, + (data: VRCConfigStateChangeResponseT) => { + setState(data as VRCConfigState); + } + ); + + const invalidConfig = useMemo(() => { + if (!state?.isSupported) return false; + return Object.values(state.validity).some((v) => !v); + }, [state]); + + return { + state, + invalidConfig, + }; +} diff --git a/server/core/src/main/java/dev/slimevr/VRServer.kt b/server/core/src/main/java/dev/slimevr/VRServer.kt index b6d1d49cc..0dd33d4a8 100644 --- a/server/core/src/main/java/dev/slimevr/VRServer.kt +++ b/server/core/src/main/java/dev/slimevr/VRServer.kt @@ -7,6 +7,9 @@ import dev.slimevr.bridge.ISteamVRBridge import dev.slimevr.config.ConfigManager import dev.slimevr.firmware.FirmwareUpdateHandler import dev.slimevr.firmware.SerialFlashingHandler +import dev.slimevr.games.vrchat.VRCConfigHandler +import dev.slimevr.games.vrchat.VRCConfigHandlerStub +import dev.slimevr.games.vrchat.VRChatConfigManager import dev.slimevr.osc.OSCHandler import dev.slimevr.osc.OSCRouter import dev.slimevr.osc.VMCHandler @@ -50,6 +53,7 @@ class VRServer @JvmOverloads constructor( bridgeProvider: BridgeProvider = { _, _ -> sequence {} }, serialHandlerProvider: (VRServer) -> SerialHandler = { _ -> SerialHandlerStub() }, flashingHandlerProvider: (VRServer) -> SerialFlashingHandler? = { _ -> null }, + vrcConfigHandlerProvider: (VRServer) -> VRCConfigHandler = { _ -> VRCConfigHandlerStub() }, acquireMulticastLock: () -> Any? = { null }, // configPath is used by VRWorkout, do not remove! configPath: String, @@ -87,6 +91,8 @@ class VRServer @JvmOverloads constructor( val firmwareUpdateHandler: FirmwareUpdateHandler + val vrcConfigManager: VRChatConfigManager + @JvmField val autoBoneHandler: AutoBoneHandler @@ -124,6 +130,7 @@ class VRServer @JvmOverloads constructor( // AutoBone requires HumanPoseManager first autoBoneHandler = AutoBoneHandler(this) firmwareUpdateHandler = FirmwareUpdateHandler(this) + vrcConfigManager = VRChatConfigManager(this, vrcConfigHandlerProvider(this)) protocolAPI = ProtocolAPI(this) val computedTrackers = humanPoseManager.computedTrackers diff --git a/server/core/src/main/java/dev/slimevr/games/vrchat/VRCConfigHandler.kt b/server/core/src/main/java/dev/slimevr/games/vrchat/VRCConfigHandler.kt new file mode 100644 index 000000000..8e54d5de8 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/games/vrchat/VRCConfigHandler.kt @@ -0,0 +1,179 @@ +package dev.slimevr.games.vrchat + +import dev.slimevr.VRServer +import dev.slimevr.tracking.processor.config.SkeletonConfigToggles +import dev.slimevr.tracking.trackers.TrackerPosition +import dev.slimevr.tracking.trackers.TrackerUtils +import java.util.concurrent.CopyOnWriteArrayList +import kotlin.math.* + +enum class VRCTrackerModel(val value: Int, val id: Int) { + UNKNOWN(-1, solarxr_protocol.rpc.VRCTrackerModel.UNKNOWN), + SPHERE(0, solarxr_protocol.rpc.VRCTrackerModel.SPHERE), + SYSTEM(1, solarxr_protocol.rpc.VRCTrackerModel.SYSTEM), + BOX(2, solarxr_protocol.rpc.VRCTrackerModel.BOX), + AXIS(3, solarxr_protocol.rpc.VRCTrackerModel.AXIS), + ; + + companion object { + private val byValue = VRCTrackerModel.entries.associateBy { it.value } + + fun getByValue(value: Int): VRCTrackerModel? = byValue[value] + } +} + +enum class VRCSpineMode(val value: Int, val id: Int) { + UNKNOWN(-1, solarxr_protocol.rpc.VRCSpineMode.UNKNOWN), + LOCK_HIP(0, solarxr_protocol.rpc.VRCSpineMode.LOCK_HIP), + LOCK_HEAD(1, solarxr_protocol.rpc.VRCSpineMode.LOCK_HEAD), + LOCK_BOTH(2, solarxr_protocol.rpc.VRCSpineMode.LOCK_BOTH), + ; + + companion object { + private val byValue = VRCSpineMode.entries.associateBy { it.value } + + fun getByValue(value: Int): VRCSpineMode? = byValue[value] + } +} + +enum class VRCAvatarMeasurementType(val value: Int, val id: Int) { + UNKNOWN(-1, solarxr_protocol.rpc.VRCAvatarMeasurementType.UNKNOWN), + ARM_SPAN(0, solarxr_protocol.rpc.VRCAvatarMeasurementType.ARM_SPAN), + HEIGHT(1, solarxr_protocol.rpc.VRCAvatarMeasurementType.HEIGHT), + ; + + companion object { + private val byValue = VRCAvatarMeasurementType.entries.associateBy { it.value } + + fun getByValue(value: Int): VRCAvatarMeasurementType? = byValue[value] + } +} + +data class VRCConfigValues( + val legacyMode: Boolean, + val shoulderTrackingDisabled: Boolean, + val shoulderWidthCompensation: Boolean, + val userHeight: Double, + val calibrationRange: Double, + val calibrationVisuals: Boolean, + val trackerModel: VRCTrackerModel, + val spineMode: VRCSpineMode, + val avatarMeasurementType: VRCAvatarMeasurementType, +) + +data class VRCConfigRecommendedValues( + val legacyMode: Boolean, + val shoulderTrackingDisabled: Boolean, + val shoulderWidthCompensation: Boolean, + val userHeight: Double, + val calibrationRange: Double, + val calibrationVisuals: Boolean, + val trackerModel: VRCTrackerModel, + val spineMode: Array, + val avatarMeasurementType: VRCAvatarMeasurementType, +) + +data class VRCConfigValidity( + val legacyModeOk: Boolean, + val shoulderTrackingOk: Boolean, + val shoulderWidthCompensationOk: Boolean, + val userHeightOk: Boolean, + val calibrationOk: Boolean, + val calibrationVisualsOk: Boolean, + val tackerModelOk: Boolean, + val spineModeOk: Boolean, + val avatarMeasurementOk: Boolean, +) + +abstract class VRCConfigHandler { + abstract val isSupported: Boolean + abstract fun initHandler(onChange: (config: VRCConfigValues) -> Unit) +} + +class VRCConfigHandlerStub : VRCConfigHandler() { + override val isSupported: Boolean + get() = false + + override fun initHandler(onChange: (config: VRCConfigValues) -> Unit) {} +} + +interface VRCConfigListener { + fun onChange(validity: VRCConfigValidity, values: VRCConfigValues, recommended: VRCConfigRecommendedValues) +} + +class VRChatConfigManager(val server: VRServer, private val handler: VRCConfigHandler) { + + private val listeners: MutableList = CopyOnWriteArrayList() + var currentValues: VRCConfigValues? = null + + val isSupported: Boolean + get() = handler.isSupported + + init { + handler.initHandler(::onChange) + } + + /** + * shoulderTrackingDisabled should be true if: + * The user isn't tracking their whole arms from their controllers: + * forceArmsFromHMD is enabled || the user doesn't have hand trackers with position || the user doesn't have lower arms trackers || the user doesn't have upper arm trackers + * And the user isn't tracking their arms from their HMD or doesn't have both shoulders: + * (forceArmsFromHMD is disabled && user has hand trackers with position) || user is missing a shoulder tracker + */ + fun recommendedValues(): VRCConfigRecommendedValues { + val forceArmsFromHMD = server.humanPoseManager.getToggle(SkeletonConfigToggles.FORCE_ARMS_FROM_HMD) + + val hasLeftHandWithPosition = TrackerUtils.getTrackerForSkeleton(server.allTrackers, TrackerPosition.LEFT_HAND)?.hasPosition ?: false + val hasRightHandWithPosition = TrackerUtils.getTrackerForSkeleton(server.allTrackers, TrackerPosition.RIGHT_HAND)?.hasPosition ?: false + + val isMissingAnArmTracker = TrackerUtils.getTrackerForSkeleton(server.allTrackers, TrackerPosition.LEFT_LOWER_ARM) == null || + TrackerUtils.getTrackerForSkeleton(server.allTrackers, TrackerPosition.RIGHT_LOWER_ARM) == null || + TrackerUtils.getTrackerForSkeleton(server.allTrackers, TrackerPosition.LEFT_UPPER_ARM) == null || + TrackerUtils.getTrackerForSkeleton(server.allTrackers, TrackerPosition.RIGHT_UPPER_ARM) == null + val isMissingAShoulderTracker = TrackerUtils.getTrackerForSkeleton(server.allTrackers, TrackerPosition.LEFT_SHOULDER) == null || + TrackerUtils.getTrackerForSkeleton(server.allTrackers, TrackerPosition.RIGHT_SHOULDER) == null + + return VRCConfigRecommendedValues( + legacyMode = false, + shoulderTrackingDisabled = + ((forceArmsFromHMD || !hasLeftHandWithPosition || !hasRightHandWithPosition) || isMissingAnArmTracker) && // Not tracking shoulders from hands + ((!forceArmsFromHMD && hasLeftHandWithPosition && hasRightHandWithPosition) || isMissingAShoulderTracker), // Not tracking shoulders from HMD + userHeight = server.humanPoseManager.realUserHeight.toDouble(), + calibrationRange = 0.2, + trackerModel = VRCTrackerModel.AXIS, + spineMode = arrayOf(VRCSpineMode.LOCK_HIP, VRCSpineMode.LOCK_HEAD), + calibrationVisuals = true, + avatarMeasurementType = VRCAvatarMeasurementType.HEIGHT, + shoulderWidthCompensation = true, + ) + } + + fun addListener(listener: VRCConfigListener) { + listeners.add(listener) + } + + fun removeListener(listener: VRCConfigListener) { + listeners.removeIf { l -> l === listener } + } + + fun checkValidity(values: VRCConfigValues, recommended: VRCConfigRecommendedValues): VRCConfigValidity = VRCConfigValidity( + legacyModeOk = values.legacyMode == recommended.legacyMode, + shoulderTrackingOk = values.shoulderTrackingDisabled == recommended.shoulderTrackingDisabled, + spineModeOk = recommended.spineMode.contains(values.spineMode), + tackerModelOk = values.trackerModel == recommended.trackerModel, + calibrationOk = abs(values.calibrationRange - recommended.calibrationRange) < 0.1, + userHeightOk = abs(server.humanPoseManager.realUserHeight - values.userHeight) < 0.1, + calibrationVisualsOk = values.calibrationVisuals == recommended.calibrationVisuals, + avatarMeasurementOk = values.avatarMeasurementType == recommended.avatarMeasurementType, + shoulderWidthCompensationOk = values.shoulderWidthCompensation == recommended.shoulderWidthCompensation, + ) + + fun onChange(values: VRCConfigValues) { + val recommended = recommendedValues() + val validity = checkValidity(values, recommended) + currentValues = values + listeners.forEach { + it.onChange(validity, values, recommended) + } + } +} diff --git a/server/core/src/main/java/dev/slimevr/protocol/rpc/RPCBuilder.java b/server/core/src/main/java/dev/slimevr/protocol/rpc/RPCBuilder.java new file mode 100644 index 000000000..ccbcf948c --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/protocol/rpc/RPCBuilder.java @@ -0,0 +1,28 @@ +package dev.slimevr.protocol.rpc; + +import com.google.flatbuffers.FlatBufferBuilder; +import dev.slimevr.tracking.processor.HumanPoseManager; +import dev.slimevr.tracking.processor.config.SkeletonConfigOffsets; +import solarxr_protocol.rpc.SkeletonConfigResponse; +import solarxr_protocol.rpc.SkeletonPart; + + +public class RPCBuilder { + + public static int createSkeletonConfig( + FlatBufferBuilder fbb, + HumanPoseManager humanPoseManager + ) { + int[] partsOffsets = new int[SkeletonConfigOffsets.values().length]; + + for (int index = 0; index < SkeletonConfigOffsets.values().length; index++) { + SkeletonConfigOffsets val = SkeletonConfigOffsets.values[index]; + int part = SkeletonPart + .createSkeletonPart(fbb, val.id, humanPoseManager.getOffset(val)); + partsOffsets[index] = part; + } + + int parts = SkeletonConfigResponse.createSkeletonPartsVector(fbb, partsOffsets); + return SkeletonConfigResponse.createSkeletonConfigResponse(fbb, parts, 0); + } +} diff --git a/server/core/src/main/java/dev/slimevr/protocol/rpc/RPCHandler.kt b/server/core/src/main/java/dev/slimevr/protocol/rpc/RPCHandler.kt index 9176ff6c0..6cf4615f9 100644 --- a/server/core/src/main/java/dev/slimevr/protocol/rpc/RPCHandler.kt +++ b/server/core/src/main/java/dev/slimevr/protocol/rpc/RPCHandler.kt @@ -8,6 +8,7 @@ import dev.slimevr.protocol.ProtocolHandler import dev.slimevr.protocol.datafeed.DataFeedBuilder import dev.slimevr.protocol.rpc.autobone.RPCAutoBoneHandler import dev.slimevr.protocol.rpc.firmware.RPCFirmwareUpdateHandler +import dev.slimevr.protocol.rpc.games.vrchat.RPCVRChatHandler import dev.slimevr.protocol.rpc.reset.RPCResetHandler import dev.slimevr.protocol.rpc.serial.RPCProvisioningHandler import dev.slimevr.protocol.rpc.serial.RPCSerialHandler @@ -44,6 +45,7 @@ class RPCHandler(private val api: ProtocolAPI) : ProtocolHandler + this.onConfigStateRequest( + conn, + messageHeader, + ) + } + } + + private fun onConfigStateRequest(conn: GenericConnection, messageHeader: RpcMessageHeader) { + val fbb = FlatBufferBuilder(32) + + val configManager = api.server.vrcConfigManager + val values = configManager.currentValues + val recommended = configManager.recommendedValues() + // FUCKING KOTLIN BRING ME BACK MY FUCKING TERNARY OPERATORS!!!!!!!!!!!!!!!!! - With love <3 Futura + val validity = if (values !== null) configManager.checkValidity(values, recommended) else null + + val response = buildVRCConfigStateResponse( + fbb, + isSupported = api.server.vrcConfigManager.isSupported, + validity = validity, + values = values, + recommended = api.server.vrcConfigManager.recommendedValues(), + ) + + val outbound = rpcHandler.createRPCMessage( + fbb, + RpcMessage.VRCConfigStateChangeResponse, + response, + ) + fbb.finish(outbound) + conn.send(fbb.dataBuffer()) + } + + override fun onChange(validity: VRCConfigValidity, values: VRCConfigValues, recommended: VRCConfigRecommendedValues) { + val fbb = FlatBufferBuilder(32) + + val response = buildVRCConfigStateResponse( + fbb, + isSupported = api.server.vrcConfigManager.isSupported, + validity = validity, + values = values, + recommended = recommended, + ) + + val outbound = rpcHandler.createRPCMessage( + fbb, + RpcMessage.VRCConfigStateChangeResponse, + response, + ) + fbb.finish(outbound) + + this.api.apiServers.forEach { apiServer -> + apiServer.apiConnections.forEach { it.send(fbb.dataBuffer()) } + } + } +} diff --git a/server/core/src/main/java/dev/slimevr/tracking/processor/HumanPoseManager.kt b/server/core/src/main/java/dev/slimevr/tracking/processor/HumanPoseManager.kt index 976032aad..9c878e554 100644 --- a/server/core/src/main/java/dev/slimevr/tracking/processor/HumanPoseManager.kt +++ b/server/core/src/main/java/dev/slimevr/tracking/processor/HumanPoseManager.kt @@ -3,6 +3,7 @@ package dev.slimevr.tracking.processor import com.jme3.math.FastMath import dev.slimevr.VRServer import dev.slimevr.VRServer.Companion.getNextLocalTrackerId +import dev.slimevr.autobone.errors.BodyProportionError import dev.slimevr.config.ConfigManager import dev.slimevr.tracking.processor.config.SkeletonConfigManager import dev.slimevr.tracking.processor.config.SkeletonConfigOffsets @@ -641,6 +642,10 @@ class HumanPoseManager(val server: VRServer?) { val userHeightFromConfig: Float get() = skeletonConfigManager.userHeightFromOffsets + @get:ThreadSafe + val realUserHeight: Float + get() = skeletonConfigManager.userHeightFromOffsets / BodyProportionError.eyeHeightToHeightRatio + // #endregion fun getPauseTracking(): Boolean = skeleton.getPauseTracking() diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt b/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt index 675336e61..5a94921c9 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt @@ -7,6 +7,7 @@ import dev.slimevr.SLIMEVR_IDENTIFIER import dev.slimevr.VRServer import dev.slimevr.bridge.Bridge import dev.slimevr.desktop.firmware.DesktopSerialFlashingHandler +import dev.slimevr.desktop.games.vrchat.DesktopVRCConfigHandler import dev.slimevr.desktop.platform.SteamVRBridge import dev.slimevr.desktop.platform.linux.UnixSocketBridge import dev.slimevr.desktop.platform.linux.UnixSocketRpcBridge @@ -123,6 +124,7 @@ fun main(args: Array) { ::provideBridges, { _ -> DesktopSerialHandler() }, { _ -> DesktopSerialFlashingHandler() }, + { _ -> DesktopVRCConfigHandler() }, configPath = configDir, ) vrServer.start() diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/games/vrchat/DesktopVRCConfigHandler.kt b/server/desktop/src/main/java/dev/slimevr/desktop/games/vrchat/DesktopVRCConfigHandler.kt new file mode 100644 index 000000000..682ea638a --- /dev/null +++ b/server/desktop/src/main/java/dev/slimevr/desktop/games/vrchat/DesktopVRCConfigHandler.kt @@ -0,0 +1,126 @@ +package dev.slimevr.desktop.games.vrchat + +import com.sun.jna.Memory +import com.sun.jna.platform.win32.Advapi32 +import com.sun.jna.platform.win32.Advapi32Util +import com.sun.jna.platform.win32.WinNT +import com.sun.jna.platform.win32.WinReg +import com.sun.jna.ptr.IntByReference +import dev.slimevr.games.vrchat.VRCAvatarMeasurementType +import dev.slimevr.games.vrchat.VRCConfigHandler +import dev.slimevr.games.vrchat.VRCConfigValues +import dev.slimevr.games.vrchat.VRCSpineMode +import dev.slimevr.games.vrchat.VRCTrackerModel +import io.eiren.util.OperatingSystem +import java.util.Timer +import kotlin.concurrent.timerTask + +// Vrchat is dumb and write 64 bit doubles in the registry as DWORD instead of QWORD. +// so we have to be creative +fun getQwordValue(path: String, key: String): Double? { + val hKey = WinReg.HKEY_CURRENT_USER + val phkResult = WinReg.HKEYByReference() + + // Open the registry key + if (Advapi32.INSTANCE.RegOpenKeyEx(hKey, path, 0, WinNT.KEY_READ, phkResult) != 0) { + println("Error: Cannot open registry key") + return null + } + + val lpData = Memory(8) + val lpcbData = IntByReference(8) + + val result = Advapi32.INSTANCE.RegQueryValueEx( + phkResult.value, + key, + 0, + null, + lpData, + lpcbData, + ) + Advapi32.INSTANCE.RegCloseKey(phkResult.value) + + if (result != 0) { + println("Error: Cannot read registry key") + return null + } + return lpData.getDouble(0) +} + +fun getDwordValue(path: String, key: String): Int? = try { + val data = Advapi32Util.registryGetIntValue(WinReg.HKEY_CURRENT_USER, path, key) + data +} catch (e: Exception) { + println("Error reading DWORD: ${e.message}") + null +} + +fun getVRChatKeys(path: String): Map { + val keysMap = mutableMapOf() + + try { + Advapi32Util.registryGetValues(WinReg.HKEY_CURRENT_USER, path).forEach { + keysMap[it.key.replace("""_h\d+$""".toRegex(), "")] = it.key + } + } catch (e: Exception) { + println("Error reading Values from VRC registry: ${e.message}") + } + return keysMap +} + +const val VRC_REG_PATH = "Software\\VRChat\\VRChat" + +class DesktopVRCConfigHandler : VRCConfigHandler() { + + private val getDevicesTimer = Timer("FetchVRCConfigTimer") + + private var configState: VRCConfigValues? = null + private var vrcConfigKeys = getVRChatKeys(VRC_REG_PATH) + lateinit var onChange: (config: VRCConfigValues) -> Unit + + private fun intValue(key: String): Int? { + val realKey = vrcConfigKeys[key] ?: return null + return getDwordValue(VRC_REG_PATH, realKey) + } + + private fun doubleValue(key: String): Double? { + val realKey = vrcConfigKeys[key] ?: return null + return getQwordValue(VRC_REG_PATH, realKey) + } + + private fun updateCurrentState() { + vrcConfigKeys = getVRChatKeys(VRC_REG_PATH) + val newConfig = VRCConfigValues( + legacyMode = intValue("VRC_IK_LEGACY") == 1, + shoulderTrackingDisabled = intValue("VRC_IK_DISABLE_SHOULDER_TRACKING") == 1, + userHeight = doubleValue("PlayerHeight") ?: -1.0, + calibrationRange = doubleValue("VRC_IK_CALIBRATION_RANGE") ?: -1.0, + trackerModel = VRCTrackerModel.getByValue(intValue("VRC_IK_TRACKER_MODEL") ?: -1) ?: VRCTrackerModel.UNKNOWN, + spineMode = VRCSpineMode.getByValue(intValue("VRC_IK_FBT_SPINE_MODE") ?: -1) ?: VRCSpineMode.UNKNOWN, + calibrationVisuals = intValue("VRC_IK_CALIBRATION_VIS") == 1, + avatarMeasurementType = VRCAvatarMeasurementType.getByValue(intValue("VRC_IK_AVATAR_MEASUREMENT_TYPE") ?: -1) ?: VRCAvatarMeasurementType.UNKNOWN, + shoulderWidthCompensation = intValue("VRC_IK_SHOULDER_WIDTH_COMPENSATION") == 1, + ) + if (newConfig != configState) { + configState = newConfig + onChange(newConfig) + } + } + + override val isSupported: Boolean + get() = OperatingSystem.currentPlatform === OperatingSystem.WINDOWS && vrcConfigKeys.isNotEmpty() + + override fun initHandler(onChange: (config: VRCConfigValues) -> Unit) { + this.onChange = onChange + if (isSupported) { + updateCurrentState() + getDevicesTimer.scheduleAtFixedRate( + timerTask { + updateCurrentState() + }, + 0, + 3000, + ) + } + } +} diff --git a/solarxr-protocol b/solarxr-protocol index b0b5d6c9f..74cdc8b7b 160000 --- a/solarxr-protocol +++ b/solarxr-protocol @@ -1 +1 @@ -Subproject commit b0b5d6c9f538c0980591e0bde868c5ac0b917f83 +Subproject commit 74cdc8b7be65c830ac48cd1826b41fb8eb431332