VRChat Config Warnings (#1358)

Co-authored-by: Erimel <marioluigivideo@gmail.com>
This commit is contained in:
lucas lelievre
2025-04-23 14:36:29 +02:00
committed by GitHub
parent edbaf49e7a
commit 0dc073ca48
19 changed files with 965 additions and 11 deletions

View File

@@ -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 <b>{$
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 <a>documentation on this topic!</a>
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 }

View File

@@ -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() {
</MainLayout>
}
/>
<Route
path="/vrc-warnings"
element={
<MainLayout isMobile={isMobile} widgets={false}>
<VRCWarningsPage />
</MainLayout>
}
/>
<Route
path="/settings"
element={

View File

@@ -64,7 +64,7 @@ export function WarningBox({
>
<WarningIcon></WarningIcon>
</div>
<div className="flex flex-col justify-center">
<div className="flex flex-col justify-center w-full">
<Typography
color="text-background-60"
whitespace={whitespace ? 'whitespace-pre-line' : undefined}

View File

@@ -1,8 +1,8 @@
export function CheckIcon(_props: any) {
export function CheckIcon({ size = 9 }: { size?: number }) {
return (
<svg
width="9"
height="7"
width={size}
height={size}
viewBox="0 0 9 7"
xmlns="http://www.w3.org/2000/svg"
>

View File

@@ -1,6 +1,11 @@
export function CrossIcon() {
export function CrossIcon({ size = 20 }: { size: number }) {
return (
<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<svg
viewBox="0 0 20 20"
width={size}
height={20}
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"

View File

@@ -1,5 +1,5 @@
import { Localized, useLocalization } from '@fluent/react';
import { NavLink, useNavigate } from 'react-router-dom';
import { Link, NavLink, useNavigate } from 'react-router-dom';
import { StatusData, TrackerDataT } from 'solarxr-protocol';
import { useConfig } from '@/hooks/config';
import { useTrackers } from '@/hooks/tracker';
@@ -15,6 +15,7 @@ import { useMemo } from 'react';
import { WarningBox } from '@/components/commons/TipBox';
import { HeadsetIcon } from '@/components/commons/icon/HeadsetIcon';
import classNames from 'classnames';
import { useVRCConfig } from '@/hooks/vrc-config';
const DONT_REPEAT_STATUSES = [StatusData.StatusTrackerReset];
@@ -23,6 +24,7 @@ export function Home() {
const { config } = useConfig();
const { trackers } = useTrackers();
const { statuses } = useStatusContext();
const { invalidConfig } = useVRCConfig();
const navigate = useNavigate();
const sendToSettings = (tracker: TrackerDataT) => {
@@ -51,9 +53,7 @@ export function Home() {
<div className="h-full overflow-y-auto">
<div
className={classNames(
'px-3 pt-3 gap-3 w-full grid md:grid-cols-2 mobile:grid-cols-1',
filteredStatuses.filter(([, status]) => 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() {
</WarningBox>
</Localized>
))}
{invalidConfig && (
<WarningBox whitespace={false}>
<div className="flex gap-2 justify-between items-center w-full">
<div className="flex">
<Localized id={'vrc_config-invalid'}></Localized>
</div>
<div className="flex">
<Link to="/vrc-warnings">
<div className="rounded-md p-2 bg-background-90 bg-opacity-15 hover:bg-background-10 hover:bg-opacity-25 text-nowrap">
<Localized id={'vrc_config-show_more'}></Localized>
</div>
</Link>
</div>
</div>
</WarningBox>
)}
</div>
<div className="overflow-y-auto flex flex-col gap-3">
{trackers.length === 0 && (

View File

@@ -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 (
<tr className="group border-b border-background-60">
<td className="px-6 py-4 flex gap-2 fill-status-success items-center">
{valid ? (
<CheckIcon size={20} />
) : (
<WarningIcon width={20} className="text-status-warning" />
)}
<Localized id={name}>
<Typography>{name}</Typography>
</Localized>
</td>
<td className="px-6 py-4 text-end items-center">{recommendedValue}</td>
<td
className={classNames(
'px-6 py-4 text-end items-center',
!valid && 'text-status-warning'
)}
>
{value}
</td>
</tr>
);
}
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 (
<div className="flex flex-col p-4 w-full">
<div className="flex flex-col max-w-lg mobile:w-full gap-3">
<Localized id={'vrc_config-page-title'}>
<Typography variant="main-title" />
</Localized>
<Localized id={'vrc_config-page-desc'}>
<Typography variant="standard" color="secondary" />
</Localized>
</div>
<div className="w-full mt-4 gap-2 flex flex-col">
<div className="-m-2 overflow-x-auto">
<div className="p-2 min-w-full inline-block align-middle">
<div className="overflow-hidden flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Localized id="vrc_config-page-big_menu">
<Typography variant="section-title" />
</Localized>
<Localized id="vrc_config-page-big_menu-desc">
<Typography color="secondary" />
</Localized>
<table className="min-w-full divide-y divide-background-50">
<thead>
<tr>
<th scope="col" className="px-6 py-3 text-start">
<Localized id={'vrc_config-setting_name'}>
<Typography />
</Localized>
</th>
<th scope="col" className="px-6 py-3 text-end">
<Localized id={'vrc_config-recommended_value'}>
<Typography />
</Localized>
</th>
<th scope="col" className="px-6 py-3 text-end">
<Localized id={'vrc_config-current_value'}>
<Typography />
</Localized>
</th>
</tr>
</thead>
<tbody>
<SettingRow
name="vrc_config-user_height"
recommendedValue={meterFormat.format(
state.recommended.userHeight
)}
value={meterFormat.format(state.state.userHeight)}
valid={state.validity.userHeightOk}
></SettingRow>
<SettingRow
name="vrc_config-legacy_mode"
recommendedValue={
<Localized
id={onOffKey(state.recommended.legacyMode)}
></Localized>
}
value={
<Localized
id={onOffKey(state.state.legacyMode)}
></Localized>
}
valid={state.validity.legacyModeOk}
></SettingRow>
<SettingRow
name="vrc_config-disable_shoulder_tracking"
recommendedValue={
<Localized
id={onOffKey(
state.recommended.shoulderTrackingDisabled
)}
></Localized>
}
value={
<Localized
id={onOffKey(state.state.shoulderTrackingDisabled)}
></Localized>
}
valid={state.validity.shoulderTrackingOk}
></SettingRow>
<SettingRow
name="vrc_config-shoulder_width_compensation"
recommendedValue={
<Localized
id={onOffKey(
state.recommended.shoulderWidthCompensation
)}
></Localized>
}
value={
<Localized
id={onOffKey(state.state.shoulderWidthCompensation)}
></Localized>
}
valid={state.validity.shoulderWidthCompensationOk}
></SettingRow>
<SettingRow
name="vrc_config-calibration_visuals"
recommendedValue={
<Localized
id={onOffKey(state.recommended.calibrationVisuals)}
></Localized>
}
value={
<Localized
id={onOffKey(state.state.calibrationVisuals)}
></Localized>
}
valid={state.validity.calibrationVisualsOk}
></SettingRow>
<SettingRow
name="vrc_config-calibration_range"
recommendedValue={meterFormat.format(
state.recommended.calibrationRange
)}
value={meterFormat.format(state.state.calibrationRange)}
valid={state.validity.calibrationRangeOk}
></SettingRow>
<SettingRow
name="vrc_config-tracker_model"
recommendedValue={
<Localized
id={
trackerModelTranslationMap[
state.recommended.trackerModel
]
}
></Localized>
}
value={
<Localized
id={
trackerModelTranslationMap[state.state.trackerModel]
}
></Localized>
}
valid={state.validity.trackerModelOk}
></SettingRow>
</tbody>
</table>
</div>
<div className="flex flex-col gap-2">
<Localized id="vrc_config-page-wrist_menu">
<Typography variant="section-title" />
</Localized>
<Localized id="vrc_config-page-wrist_menu-desc">
<Typography color="secondary" />
</Localized>
<table className="min-w-full divide-y divide-background-50">
<thead>
<tr>
<th scope="col" className="px-6 py-3 text-start">
<Localized id={'vrc_config-setting_name'}>
<Typography />
</Localized>
</th>
<th scope="col" className="px-6 py-3 text-end">
<Localized id={'vrc_config-recommended_value'}>
<Typography />
</Localized>
</th>
<th scope="col" className="px-6 py-3 text-end">
<Localized id={'vrc_config-current_value'}>
<Typography />
</Localized>
</th>
</tr>
</thead>
<tbody>
<SettingRow
name="vrc_config-spine_mode"
recommendedValue={state.recommended.spineMode
.map((mode) =>
l10n.getString(spineModeTranslationMap[mode])
)
.join(', ')}
value={
<Localized
id={spineModeTranslationMap[state.state.spineMode]}
></Localized>
}
valid={state.validity.spineModeOk}
></SettingRow>
<SettingRow
name="vrc_config-avatar_measurement_type"
recommendedValue={
<Localized
id={
avatarMeasurementTypeTranslationMap[
state.recommended.avatarMeasurementType
]
}
></Localized>
}
value={
<Localized
id={
avatarMeasurementTypeTranslationMap[
state.state.avatarMeasurementType
]
}
></Localized>
}
valid={state.validity.avatarMeasurementTypeOk}
></SettingRow>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<div className="flex flex-col max-w-lg mobile:w-full gap-2 mt-4">
<Localized id={'vrc_config-page-help'}>
<Typography variant="section-title" />
</Localized>
<Localized
id={'vrc_config-page-help-desc'}
elems={{
a: <A href="https://docs.slimevr.dev/tools/vrchat-config.html"></A>,
}}
>
<Typography color="secondary" />
</Localized>
</div>
</div>
);
}

View File

@@ -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<CreateBoardConfigDTO['type'], BoardType> =

View File

@@ -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<T> = {
[P in keyof T]: NonNullable<T[P]>;
};
export type VRCConfigState =
| { isSupported: false }
| ({ isSupported: true } & NonNull<
Pick<VRCConfigStateChangeResponseT, 'recommended' | 'state' | 'validity'>
>);
export const spineModeTranslationMap: Record<VRCSpineMode, string> = {
[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, string> = {
[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<VRCConfigState | null>(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,
};
}

View File

@@ -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

View File

@@ -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<VRCSpineMode>,
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<VRCConfigListener> = 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)
}
}
}

View File

@@ -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);
}
}

View File

@@ -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<RpcMessageHeade
RPCHandshakeHandler(this, api)
RPCTrackingPause(this, api)
RPCFirmwareUpdateHandler(this, api)
RPCVRChatHandler(this, api)
registerPacketListener(
RpcMessage.ResetRequest,

View File

@@ -0,0 +1,81 @@
package dev.slimevr.protocol.rpc.games.vrchat
import com.google.flatbuffers.FlatBufferBuilder
import solarxr_protocol.rpc.*
fun buildVRCConfigValues(fbb: FlatBufferBuilder, values: dev.slimevr.games.vrchat.VRCConfigValues): Int {
VRCConfigValues.startVRCConfigValues(fbb)
VRCConfigValues.addCalibrationRange(fbb, values.calibrationRange.toFloat())
VRCConfigValues.addCalibrationVisuals(fbb, values.calibrationVisuals)
VRCConfigValues.addSpineMode(fbb, values.spineMode.id)
VRCConfigValues.addLegacyMode(fbb, values.legacyMode)
VRCConfigValues.addShoulderTrackingDisabled(fbb, values.shoulderTrackingDisabled)
VRCConfigValues.addTrackerModel(fbb, values.trackerModel.id)
VRCConfigValues.addAvatarMeasurementType(fbb, values.avatarMeasurementType.id)
VRCConfigValues.addUserHeight(fbb, values.userHeight.toFloat())
VRCConfigValues.addShoulderWidthCompensation(fbb, values.shoulderWidthCompensation)
return VRCConfigValues.endVRCConfigValues(fbb)
}
fun buildVRCConfigValidity(fbb: FlatBufferBuilder, validity: dev.slimevr.games.vrchat.VRCConfigValidity): Int {
VRCConfigValidity.startVRCConfigValidity(fbb)
VRCConfigValidity.addCalibrationRangeOk(fbb, validity.calibrationOk)
VRCConfigValidity.addCalibrationVisualsOk(fbb, validity.calibrationVisualsOk)
VRCConfigValidity.addSpineModeOk(fbb, validity.spineModeOk)
VRCConfigValidity.addLegacyModeOk(fbb, validity.legacyModeOk)
VRCConfigValidity.addShoulderTrackingOk(fbb, validity.shoulderTrackingOk)
VRCConfigValidity.addTrackerModelOk(fbb, validity.tackerModelOk)
VRCConfigValidity.addUserHeightOk(fbb, validity.userHeightOk)
VRCConfigValidity.addAvatarMeasurementTypeOk(fbb, validity.avatarMeasurementOk)
VRCConfigValidity.addShoulderWidthCompensationOk(fbb, validity.shoulderWidthCompensationOk)
return VRCConfigValidity.endVRCConfigValidity(fbb)
}
fun buildVRCConfigRecommendedValues(fbb: FlatBufferBuilder, values: dev.slimevr.games.vrchat.VRCConfigRecommendedValues): Int {
val spineModeOffset = VRCConfigRecommendedValues
.createSpineModeVector(
fbb,
values.spineMode.map { it.id.toByte() }.toByteArray(),
)
VRCConfigRecommendedValues.startVRCConfigRecommendedValues(fbb)
VRCConfigRecommendedValues.addCalibrationRange(fbb, values.calibrationRange.toFloat())
VRCConfigRecommendedValues.addCalibrationVisuals(fbb, values.calibrationVisuals)
VRCConfigRecommendedValues.addSpineMode(fbb, spineModeOffset)
VRCConfigRecommendedValues.addLegacyMode(fbb, values.legacyMode)
VRCConfigRecommendedValues.addShoulderTrackingDisabled(fbb, values.shoulderTrackingDisabled)
VRCConfigRecommendedValues.addTrackerModel(fbb, values.trackerModel.id)
VRCConfigRecommendedValues.addAvatarMeasurementType(fbb, values.avatarMeasurementType.id)
VRCConfigRecommendedValues.addUserHeight(fbb, values.userHeight.toFloat())
VRCConfigRecommendedValues.addShoulderWidthCompensation(fbb, values.shoulderWidthCompensation)
return VRCConfigRecommendedValues.endVRCConfigRecommendedValues(fbb)
}
fun buildVRCConfigStateResponse(
fbb: FlatBufferBuilder,
isSupported: Boolean,
validity: dev.slimevr.games.vrchat.VRCConfigValidity?,
values: dev.slimevr.games.vrchat.VRCConfigValues?,
recommended: dev.slimevr.games.vrchat.VRCConfigRecommendedValues?,
): Int {
if (!isSupported) {
VRCConfigStateChangeResponse.startVRCConfigStateChangeResponse(fbb)
VRCConfigStateChangeResponse.addIsSupported(fbb, false)
return VRCConfigStateChangeResponse.endVRCConfigStateChangeResponse(fbb)
}
if (validity == null || values == null || recommended == null) {
error("invalid state - all should be set")
}
val validityOffset = buildVRCConfigValidity(fbb, validity)
val valuesOffset = buildVRCConfigValues(fbb, values)
val recommendedOffset = buildVRCConfigRecommendedValues(fbb, recommended)
VRCConfigStateChangeResponse.startVRCConfigStateChangeResponse(fbb)
VRCConfigStateChangeResponse.addIsSupported(fbb, true)
VRCConfigStateChangeResponse.addValidity(fbb, validityOffset)
VRCConfigStateChangeResponse.addState(fbb, valuesOffset)
VRCConfigStateChangeResponse.addRecommended(fbb, recommendedOffset)
return VRCConfigStateChangeResponse.endVRCConfigStateChangeResponse(fbb)
}

View File

@@ -0,0 +1,77 @@
package dev.slimevr.protocol.rpc.games.vrchat
import com.google.flatbuffers.FlatBufferBuilder
import dev.slimevr.games.vrchat.VRCConfigListener
import dev.slimevr.games.vrchat.VRCConfigRecommendedValues
import dev.slimevr.games.vrchat.VRCConfigValidity
import dev.slimevr.games.vrchat.VRCConfigValues
import dev.slimevr.protocol.GenericConnection
import dev.slimevr.protocol.ProtocolAPI
import dev.slimevr.protocol.rpc.RPCHandler
import solarxr_protocol.rpc.*
class RPCVRChatHandler(
private val rpcHandler: RPCHandler,
var api: ProtocolAPI,
) : VRCConfigListener {
init {
api.server.vrcConfigManager.addListener(this)
rpcHandler.registerPacketListener(RpcMessage.VRCConfigStateRequest) { conn: GenericConnection, messageHeader: RpcMessageHeader ->
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()) }
}
}
}

View File

@@ -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()

View File

@@ -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<String>) {
::provideBridges,
{ _ -> DesktopSerialHandler() },
{ _ -> DesktopSerialFlashingHandler() },
{ _ -> DesktopVRCConfigHandler() },
configPath = configDir,
)
vrServer.start()

View File

@@ -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<String, String> {
val keysMap = mutableMapOf<String, String>()
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,
)
}
}
}