mirror of
https://github.com/SlimeVR/SlimeVR-Server.git
synced 2026-04-06 02:01:58 +02:00
VRC settings (Non tested on windows)
This commit is contained in:
@@ -14,6 +14,7 @@ object AppLogger {
|
||||
val hid = logger("HID")
|
||||
val serial = logger("Serial")
|
||||
val firmware = logger("Firmware")
|
||||
val vrc = logger("VRChat")
|
||||
|
||||
init {
|
||||
loggingConfiguration {
|
||||
|
||||
@@ -46,7 +46,7 @@ fun createSolarXRConnection(
|
||||
datafeedTimers = listOf(),
|
||||
)
|
||||
|
||||
val behaviours = listOf(DataFeedInitBehaviour, SerialConsoleBehaviour, FirmwareBehaviour)
|
||||
val behaviours = listOf(DataFeedInitBehaviour, SerialConsoleBehaviour, FirmwareBehaviour, VRCBehaviour)
|
||||
|
||||
val context = createContext(
|
||||
initialState = state,
|
||||
|
||||
47
server/core/src/main/java/dev/slimevr/solarxr/vrchat.kt
Normal file
47
server/core/src/main/java/dev/slimevr/solarxr/vrchat.kt
Normal file
@@ -0,0 +1,47 @@
|
||||
package dev.slimevr.solarxr
|
||||
|
||||
import dev.slimevr.vrchat.VRCConfigActions
|
||||
import dev.slimevr.vrchat.computeRecommendedValues
|
||||
import dev.slimevr.vrchat.computeValidity
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import solarxr_protocol.rpc.VRCConfigSettingToggleMute
|
||||
import solarxr_protocol.rpc.VRCConfigStateChangeResponse
|
||||
import solarxr_protocol.rpc.VRCConfigStateRequest
|
||||
|
||||
val VRCBehaviour = SolarXRConnectionBehaviour(
|
||||
observer = { conn ->
|
||||
val vrcManager = conn.serverContext.vrcConfigManager
|
||||
|
||||
fun buildCurrentResponse(): VRCConfigStateChangeResponse {
|
||||
val state = vrcManager.context.state.value
|
||||
val values = state.currentValues
|
||||
if (!state.isSupported || values == null) return VRCConfigStateChangeResponse(isSupported = false)
|
||||
val recommended = computeRecommendedValues(conn.serverContext, vrcManager.userHeight())
|
||||
return VRCConfigStateChangeResponse(
|
||||
isSupported = true,
|
||||
validity = computeValidity(values, recommended),
|
||||
state = values,
|
||||
recommended = recommended,
|
||||
muted = state.mutedWarnings.toList(),
|
||||
)
|
||||
}
|
||||
|
||||
// Note here that we drop the first one here
|
||||
// that is because we don't need the initial value
|
||||
// we just want to send new response when the vrch config change
|
||||
vrcManager.context.state.drop(1).onEach {
|
||||
conn.sendRpc(buildCurrentResponse())
|
||||
}.launchIn(conn.context.scope)
|
||||
|
||||
conn.rpcDispatcher.on<VRCConfigStateRequest> {
|
||||
conn.sendRpc(buildCurrentResponse())
|
||||
}
|
||||
|
||||
conn.rpcDispatcher.on<VRCConfigSettingToggleMute> { req ->
|
||||
val key = req.key ?: return@on
|
||||
vrcManager.context.dispatch(VRCConfigActions.ToggleMutedWarning(key))
|
||||
}
|
||||
},
|
||||
)
|
||||
129
server/core/src/main/java/dev/slimevr/vrchat/module.kt
Normal file
129
server/core/src/main/java/dev/slimevr/vrchat/module.kt
Normal file
@@ -0,0 +1,129 @@
|
||||
package dev.slimevr.vrchat
|
||||
|
||||
import dev.slimevr.VRServer
|
||||
import dev.slimevr.context.BasicBehaviour
|
||||
import dev.slimevr.context.Context
|
||||
import dev.slimevr.context.createContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.launch
|
||||
import solarxr_protocol.datatypes.BodyPart
|
||||
import solarxr_protocol.rpc.VRCAvatarMeasurementType
|
||||
import solarxr_protocol.rpc.VRCConfigRecommendedValues
|
||||
import solarxr_protocol.rpc.VRCConfigValidity
|
||||
import solarxr_protocol.rpc.VRCConfigValues
|
||||
import solarxr_protocol.rpc.VRCSpineMode
|
||||
import solarxr_protocol.rpc.VRCTrackerModel
|
||||
import kotlin.math.abs
|
||||
|
||||
val VRC_VALID_KEYS = setOf(
|
||||
"legacyModeOk",
|
||||
"shoulderTrackingOk",
|
||||
"shoulderWidthCompensationOk",
|
||||
"userHeightOk",
|
||||
"calibrationRangeOk",
|
||||
"calibrationVisualsOk",
|
||||
"trackerModelOk",
|
||||
"spineModeOk",
|
||||
"avatarMeasurementTypeOk",
|
||||
)
|
||||
|
||||
data class VRCConfigState(
|
||||
val currentValues: VRCConfigValues?,
|
||||
val isSupported: Boolean,
|
||||
val mutedWarnings: Set<String>,
|
||||
)
|
||||
|
||||
sealed interface VRCConfigActions {
|
||||
data class UpdateValues(val values: VRCConfigValues?) : VRCConfigActions
|
||||
data class ToggleMutedWarning(val key: String) : VRCConfigActions
|
||||
}
|
||||
|
||||
typealias VRCConfigContext = Context<VRCConfigState, VRCConfigActions>
|
||||
typealias VRCConfigBehaviour = BasicBehaviour<VRCConfigState, VRCConfigActions>
|
||||
|
||||
data class VRCConfigManager(
|
||||
val context: VRCConfigContext,
|
||||
val userHeight: () -> Double,
|
||||
)
|
||||
|
||||
val DefaultVRCConfigBehaviour = VRCConfigBehaviour(
|
||||
reducer = { s, a ->
|
||||
when (a) {
|
||||
is VRCConfigActions.UpdateValues -> s.copy(currentValues = a.values)
|
||||
is VRCConfigActions.ToggleMutedWarning -> {
|
||||
if (a.key !in VRC_VALID_KEYS) s
|
||||
else if (a.key in s.mutedWarnings) s.copy(mutedWarnings = s.mutedWarnings - a.key)
|
||||
else s.copy(mutedWarnings = s.mutedWarnings + a.key)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
fun computeRecommendedValues(server: VRServer, userHeight: Double): VRCConfigRecommendedValues {
|
||||
val trackers = server.context.state.value.trackers.values
|
||||
|
||||
fun hasTracker(bodyPart: BodyPart) = trackers.any { it.context.state.value.bodyPart == bodyPart }
|
||||
|
||||
val hasLeftHandWithPosition = hasTracker(BodyPart.LEFT_HAND)
|
||||
val hasRightHandWithPosition = hasTracker(BodyPart.RIGHT_HAND)
|
||||
|
||||
val isMissingAnArmTracker = !hasTracker(BodyPart.LEFT_LOWER_ARM) ||
|
||||
!hasTracker(BodyPart.RIGHT_LOWER_ARM) ||
|
||||
!hasTracker(BodyPart.LEFT_UPPER_ARM) ||
|
||||
!hasTracker(BodyPart.RIGHT_UPPER_ARM)
|
||||
val isMissingAShoulderTracker = !hasTracker(BodyPart.LEFT_SHOULDER) ||
|
||||
!hasTracker(BodyPart.RIGHT_SHOULDER)
|
||||
|
||||
return VRCConfigRecommendedValues(
|
||||
legacyMode = false,
|
||||
shoulderTrackingDisabled =
|
||||
(!hasLeftHandWithPosition || !hasRightHandWithPosition || isMissingAnArmTracker) &&
|
||||
((hasLeftHandWithPosition && hasRightHandWithPosition) || isMissingAShoulderTracker),
|
||||
userHeight = userHeight.toFloat(),
|
||||
calibrationRange = 0.2f,
|
||||
trackerModel = VRCTrackerModel.AXIS,
|
||||
spineMode = listOf(VRCSpineMode.LOCK_HIP, VRCSpineMode.LOCK_HEAD),
|
||||
calibrationVisuals = true,
|
||||
avatarMeasurementType = VRCAvatarMeasurementType.HEIGHT,
|
||||
shoulderWidthCompensation = true,
|
||||
)
|
||||
}
|
||||
|
||||
fun computeValidity(values: VRCConfigValues, recommended: VRCConfigRecommendedValues): VRCConfigValidity =
|
||||
VRCConfigValidity(
|
||||
legacyModeOk = values.legacyMode == recommended.legacyMode,
|
||||
shoulderTrackingOk = values.shoulderTrackingDisabled == recommended.shoulderTrackingDisabled,
|
||||
spineModeOk = recommended.spineMode?.contains(values.spineMode) == true,
|
||||
trackerModelOk = values.trackerModel == recommended.trackerModel,
|
||||
calibrationRangeOk = abs(values.calibrationRange - recommended.calibrationRange) < 0.1f,
|
||||
userHeightOk = abs(recommended.userHeight - values.userHeight) < 0.1f,
|
||||
calibrationVisualsOk = values.calibrationVisuals == recommended.calibrationVisuals,
|
||||
avatarMeasurementTypeOk = values.avatarMeasurementType == recommended.avatarMeasurementType,
|
||||
shoulderWidthCompensationOk = values.shoulderWidthCompensation == recommended.shoulderWidthCompensation,
|
||||
)
|
||||
|
||||
fun createVRCConfigManager(
|
||||
scope: CoroutineScope,
|
||||
userHeight: () -> Double,
|
||||
isSupported: Boolean,
|
||||
values: Flow<VRCConfigValues?>,
|
||||
): VRCConfigManager {
|
||||
val initialState = VRCConfigState(
|
||||
currentValues = null,
|
||||
isSupported = isSupported,
|
||||
mutedWarnings = emptySet(),
|
||||
)
|
||||
|
||||
val context = createContext(
|
||||
initialState = initialState,
|
||||
reducers = listOf(DefaultVRCConfigBehaviour.reducer),
|
||||
scope = scope,
|
||||
)
|
||||
|
||||
scope.launch {
|
||||
values.collect { context.dispatch(VRCConfigActions.UpdateValues(it)) }
|
||||
}
|
||||
|
||||
return VRCConfigManager(context = context, userHeight = userHeight)
|
||||
}
|
||||
@@ -3,10 +3,11 @@ package dev.slimevr
|
||||
import dev.slimevr.context.Context
|
||||
import dev.slimevr.context.CustomBehaviour
|
||||
import dev.slimevr.context.createContext
|
||||
import dev.slimevr.device.Device
|
||||
import dev.slimevr.firmware.FirmwareManager
|
||||
import dev.slimevr.serial.SerialServer
|
||||
import dev.slimevr.device.Device
|
||||
import dev.slimevr.tracker.Tracker
|
||||
import dev.slimevr.vrchat.VRCConfigManager
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
@@ -51,6 +52,7 @@ data class VRServer(
|
||||
val context: VRServerContext,
|
||||
val serialServer: SerialServer,
|
||||
val firmwareManager: FirmwareManager,
|
||||
val vrcConfigManager: VRCConfigManager,
|
||||
|
||||
// Moved this outside of the context to make this faster and safer to use
|
||||
private val handleCounter: AtomicInt,
|
||||
@@ -60,7 +62,12 @@ data class VRServer(
|
||||
fun getDevice(id: Int) = context.state.value.devices[id]
|
||||
|
||||
companion object {
|
||||
fun create(scope: CoroutineScope, serialServer: SerialServer, firmwareManager: FirmwareManager): VRServer {
|
||||
fun create(
|
||||
scope: CoroutineScope,
|
||||
serialServer: SerialServer,
|
||||
firmwareManager: FirmwareManager,
|
||||
vrcConfigManager: VRCConfigManager,
|
||||
): VRServer {
|
||||
val state = VRServerState(
|
||||
trackers = mapOf(),
|
||||
devices = mapOf(),
|
||||
@@ -78,6 +85,7 @@ data class VRServer(
|
||||
context = context,
|
||||
serialServer = serialServer,
|
||||
firmwareManager = firmwareManager,
|
||||
vrcConfigManager = vrcConfigManager,
|
||||
handleCounter = AtomicInt(0),
|
||||
)
|
||||
|
||||
@@ -86,4 +94,4 @@ data class VRServer(
|
||||
return server
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,9 @@ import dev.llelievr.espflashkotlin.FlasherSerialInterface
|
||||
import dev.slimevr.firmware.createFirmwareManager
|
||||
import dev.slimevr.serial.SerialPortHandle
|
||||
import dev.slimevr.serial.SerialServer
|
||||
import dev.slimevr.vrchat.createVRCConfigManager
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
|
||||
fun buildTestSerialServer(scope: CoroutineScope) = SerialServer.create(
|
||||
openPort = { loc, _, _, _ -> SerialPortHandle(loc, "Fake $loc", {}, {}) },
|
||||
@@ -27,5 +29,12 @@ fun buildTestSerialServer(scope: CoroutineScope) = SerialServer.create(
|
||||
|
||||
fun buildTestVrServer(scope: CoroutineScope): VRServer {
|
||||
val serialServer = buildTestSerialServer(scope)
|
||||
return VRServer.create(scope, serialServer, createFirmwareManager(serialServer, scope))
|
||||
return VRServer.create(scope, serialServer, createFirmwareManager(serialServer, scope),
|
||||
createVRCConfigManager(
|
||||
scope = scope,
|
||||
userHeight = { 1.6 },
|
||||
isSupported = false,
|
||||
values = emptyFlow(),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,17 +3,20 @@ package dev.slimevr.firmware
|
||||
import dev.llelievr.espflashkotlin.FlasherSerialInterface
|
||||
import dev.slimevr.VRServer
|
||||
import dev.slimevr.VRServerActions
|
||||
import dev.slimevr.device.DeviceActions
|
||||
import dev.slimevr.serial.SerialPortHandle
|
||||
import dev.slimevr.serial.SerialPortInfo
|
||||
import dev.slimevr.serial.SerialServer
|
||||
import dev.slimevr.device.DeviceOrigin
|
||||
import dev.slimevr.device.createDevice
|
||||
import dev.slimevr.vrchat.createVRCConfigManager
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import solarxr_protocol.datatypes.TrackerStatus
|
||||
import solarxr_protocol.datatypes.hardware_info.BoardType
|
||||
import solarxr_protocol.datatypes.hardware_info.McuType
|
||||
import solarxr_protocol.rpc.FirmwareUpdateStatus
|
||||
@@ -61,7 +64,13 @@ private fun buildVrServer(
|
||||
serialServer: SerialServer,
|
||||
): VRServer {
|
||||
val firmwareManager = createFirmwareManager(serialServer, mainScope)
|
||||
return VRServer.create(backgroundScope, serialServer, firmwareManager)
|
||||
val vrcConfigManager = createVRCConfigManager(
|
||||
scope = mainScope,
|
||||
userHeight = { 1.6 },
|
||||
isSupported = false,
|
||||
values = kotlinx.coroutines.flow.emptyFlow(),
|
||||
) // FIXME this is annoying. we need to find better
|
||||
return VRServer.create(backgroundScope, serialServer, firmwareManager, vrcConfigManager)
|
||||
}
|
||||
|
||||
class DoSerialFlashTest {
|
||||
@@ -306,10 +315,9 @@ class DoSerialFlashTest {
|
||||
origin = DeviceOrigin.UDP,
|
||||
protocolVersion = 0,
|
||||
serverContext = vrServer,
|
||||
boardType = BoardType.SLIMEVR,
|
||||
mcuType = McuType.ESP8266,
|
||||
)
|
||||
vrServer.context.dispatch(VRServerActions.NewDevice(device.context.state.value.id, device))
|
||||
device.context.dispatch(DeviceActions.Update { copy(status = TrackerStatus.OK) })
|
||||
}
|
||||
|
||||
advanceUntilIdle()
|
||||
|
||||
@@ -7,6 +7,7 @@ import dev.slimevr.config.createAppConfig
|
||||
import dev.slimevr.desktop.hid.createDesktopHIDManager
|
||||
import dev.slimevr.desktop.ipc.createIpcServers
|
||||
import dev.slimevr.desktop.serial.createDesktopSerialServer
|
||||
import dev.slimevr.desktop.vrchat.createDesktopVRCConfigManager
|
||||
import dev.slimevr.firmware.createFirmwareManager
|
||||
import dev.slimevr.resolveConfigDirectory
|
||||
import dev.slimevr.solarxr.createSolarXRWebsocketServer
|
||||
@@ -19,7 +20,11 @@ fun main(args: Array<String>) = runBlocking {
|
||||
val config = createAppConfig(this, configFolder = configFolder.toFile())
|
||||
val serialServer = createDesktopSerialServer(this)
|
||||
val firmwareManager = createFirmwareManager(serialServer = serialServer, scope = this)
|
||||
val server = VRServer.create(this, serialServer, firmwareManager)
|
||||
val vrcConfigManager = createDesktopVRCConfigManager(
|
||||
scope = this,
|
||||
userHeight = { config.userConfig.context.state.value.data.userHeight.toDouble() },
|
||||
)
|
||||
val server = VRServer.create(this, serialServer, firmwareManager, vrcConfigManager)
|
||||
|
||||
launch {
|
||||
createUDPTrackerServer(server, config)
|
||||
|
||||
106
server/desktop/src/main/java/dev/slimevr/desktop/vrchat/linux.kt
Normal file
106
server/desktop/src/main/java/dev/slimevr/desktop/vrchat/linux.kt
Normal file
@@ -0,0 +1,106 @@
|
||||
package dev.slimevr.desktop.vrchat
|
||||
|
||||
import dev.slimevr.AppLogger
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.BufferedReader
|
||||
import java.io.FileReader
|
||||
import java.io.InvalidObjectException
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import kotlin.io.path.Path
|
||||
import kotlin.io.path.exists
|
||||
|
||||
private const val USER_REG_SUBPATH = "steamapps/compatdata/438100/pfx/user.reg"
|
||||
private val KEY_VALUE_PATTERN = Regex(""""(.+)"=(.+)""")
|
||||
private val HEX_FORMAT = HexFormat { upperCase = false; bytes.byteSeparator = "," }
|
||||
|
||||
internal val linuxUserRegPath = System.getenv("HOME")?.let { home ->
|
||||
listOf(
|
||||
Path(home, ".steam", "root", USER_REG_SUBPATH),
|
||||
Path(home, ".steam", "debian-installation", USER_REG_SUBPATH),
|
||||
Path(home, ".var", "app", "com.valvesoftware.Steam", "data", "Steam", USER_REG_SUBPATH),
|
||||
).firstOrNull { it.exists() }
|
||||
}
|
||||
|
||||
internal suspend fun linuxGetVRChatKeys(path: String, registry: MutableMap<String, String>): Map<String, String> {
|
||||
val keysMap = mutableMapOf<String, String>()
|
||||
registry.clear()
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
BufferedReader(FileReader(linuxUserRegPath?.toFile() ?: return@withContext)).use { reader ->
|
||||
val actualPath = "[${path.replace("\\", """\\""")}]"
|
||||
while (reader.ready()) {
|
||||
val line = reader.readLine()
|
||||
if (!line.startsWith(actualPath)) continue
|
||||
reader.readLine() // skip `#time` line
|
||||
while (reader.ready()) {
|
||||
val keyValue = reader.readLine()
|
||||
if (keyValue == "") break
|
||||
KEY_VALUE_PATTERN.matchEntire(keyValue)?.let {
|
||||
registry[it.groupValues[1]] = it.groupValues[2]
|
||||
keysMap[it.groupValues[1].replace("""_h\d+$""".toRegex(), "")] = it.groupValues[1]
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
AppLogger.vrc.error("[VRChatRegEdit] Error reading VRC registry values: ${e.message}")
|
||||
}
|
||||
return keysMap
|
||||
}
|
||||
|
||||
internal suspend fun linuxGetQwordValue(registry: Map<String, String>, key: String): Double? {
|
||||
val value = registry[key] ?: return null
|
||||
if (!value.startsWith("hex(4):")) {
|
||||
AppLogger.vrc.error("[VRChatRegEdit] Unexpected registry value type for key $key")
|
||||
return null
|
||||
}
|
||||
return ByteBuffer.wrap(value.substring(7).hexToByteArray(HEX_FORMAT)).order(ByteOrder.LITTLE_ENDIAN).double
|
||||
}
|
||||
|
||||
internal suspend fun linuxGetDwordValue(registry: Map<String, String>, key: String): Int? = try {
|
||||
val value = registry[key] ?: return null
|
||||
if (value.startsWith("dword:")) value.substring(6).toInt(16)
|
||||
else throw InvalidObjectException("Expected DWORD but got: $value")
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
AppLogger.vrc.error("[VRChatRegEdit] Error reading DWORD: ${e.message}")
|
||||
null
|
||||
}
|
||||
|
||||
internal fun linuxVRCConfigFlow(): Flow<solarxr_protocol.rpc.VRCConfigValues?> = flow {
|
||||
val regPath = linuxUserRegPath ?: run {
|
||||
AppLogger.vrc.info("[VRChatRegEdit] Couldn't find any VRChat registry file")
|
||||
return@flow
|
||||
}
|
||||
AppLogger.vrc.info("[VRChatRegEdit] Using VRChat registry file: $regPath")
|
||||
|
||||
val registry = mutableMapOf<String, String>()
|
||||
while (true) {
|
||||
val keys = linuxGetVRChatKeys(VRC_REG_PATH, registry)
|
||||
if (keys.isEmpty()) {
|
||||
emit(null)
|
||||
} else {
|
||||
emit(buildVRCConfigValues(
|
||||
intValue = { key -> keys[key]?.let { linuxGetDwordValue(registry, it) } },
|
||||
doubleValue = { key -> keys[key]?.let { linuxGetQwordValue(registry, it) } },
|
||||
))
|
||||
println("EMIT")
|
||||
}
|
||||
delay(3000)
|
||||
// it seems that on linux, steam writes to the reg file is unpredictable.
|
||||
// I tried multiple things to just watch for file change instead of polling
|
||||
// without success. Polling was the simplest and most reliable
|
||||
}
|
||||
}.flowOn(Dispatchers.IO)
|
||||
@@ -0,0 +1,66 @@
|
||||
package dev.slimevr.desktop.vrchat
|
||||
|
||||
import dev.slimevr.CURRENT_PLATFORM
|
||||
import dev.slimevr.Platform
|
||||
import dev.slimevr.vrchat.VRCConfigManager
|
||||
import dev.slimevr.vrchat.createVRCConfigManager
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import solarxr_protocol.rpc.VRCAvatarMeasurementType
|
||||
import solarxr_protocol.rpc.VRCConfigValues
|
||||
import solarxr_protocol.rpc.VRCSpineMode
|
||||
import solarxr_protocol.rpc.VRCTrackerModel
|
||||
|
||||
internal const val VRC_REG_PATH = "Software\\VRChat\\VRChat"
|
||||
|
||||
fun createDesktopVRCConfigManager(scope: CoroutineScope, userHeight: () -> Double): VRCConfigManager =
|
||||
when (CURRENT_PLATFORM) {
|
||||
Platform.WINDOWS -> createVRCConfigManager(
|
||||
scope = scope,
|
||||
userHeight = userHeight,
|
||||
isSupported = true,
|
||||
values = windowsVRCConfigFlow(),
|
||||
)
|
||||
Platform.LINUX -> createVRCConfigManager(
|
||||
scope = scope,
|
||||
userHeight = userHeight,
|
||||
isSupported = true,
|
||||
values = linuxVRCConfigFlow(),
|
||||
)
|
||||
else -> createVRCConfigManager(
|
||||
scope = scope,
|
||||
userHeight = userHeight,
|
||||
isSupported = false,
|
||||
values = emptyFlow(),
|
||||
)
|
||||
}
|
||||
|
||||
internal suspend fun buildVRCConfigValues(
|
||||
intValue: suspend (String) -> Int?,
|
||||
doubleValue: suspend (String) -> Double?,
|
||||
): VRCConfigValues = VRCConfigValues(
|
||||
legacyMode = intValue("VRC_IK_LEGACY") == 1,
|
||||
shoulderTrackingDisabled = intValue("VRC_IK_DISABLE_SHOULDER_TRACKING") == 1,
|
||||
shoulderWidthCompensation = intValue("VRC_IK_SHOULDER_WIDTH_COMPENSATION") == 1,
|
||||
userHeight = doubleValue("PlayerHeight")?.toFloat() ?: -1.0f,
|
||||
calibrationRange = doubleValue("VRC_IK_CALIBRATION_RANGE")?.toFloat() ?: -1.0f,
|
||||
trackerModel = when (intValue("VRC_IK_TRACKER_MODEL")) {
|
||||
0 -> VRCTrackerModel.SPHERE
|
||||
1 -> VRCTrackerModel.SYSTEM
|
||||
2 -> VRCTrackerModel.BOX
|
||||
3 -> VRCTrackerModel.AXIS
|
||||
else -> VRCTrackerModel.UNKNOWN
|
||||
},
|
||||
spineMode = when (intValue("VRC_IK_FBT_SPINE_MODE")) {
|
||||
0 -> VRCSpineMode.LOCK_HIP
|
||||
1 -> VRCSpineMode.LOCK_HEAD
|
||||
2 -> VRCSpineMode.LOCK_BOTH
|
||||
else -> VRCSpineMode.UNKNOWN
|
||||
},
|
||||
calibrationVisuals = intValue("VRC_IK_CALIBRATION_VIS") == 1,
|
||||
avatarMeasurementType = when (intValue("VRC_IK_AVATAR_MEASUREMENT_TYPE")) {
|
||||
0 -> VRCAvatarMeasurementType.ARM_SPAN
|
||||
1 -> VRCAvatarMeasurementType.HEIGHT
|
||||
else -> VRCAvatarMeasurementType.UNKNOWN
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,107 @@
|
||||
package dev.slimevr.desktop.vrchat
|
||||
|
||||
import com.sun.jna.Library
|
||||
import com.sun.jna.Memory
|
||||
import com.sun.jna.Native
|
||||
import com.sun.jna.platform.win32.Advapi32
|
||||
import com.sun.jna.platform.win32.Advapi32Util
|
||||
import com.sun.jna.platform.win32.Kernel32
|
||||
import com.sun.jna.platform.win32.WinBase
|
||||
import com.sun.jna.platform.win32.WinNT
|
||||
import com.sun.jna.platform.win32.WinReg
|
||||
import com.sun.jna.ptr.IntByReference
|
||||
import dev.slimevr.AppLogger
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.withContext
|
||||
import solarxr_protocol.rpc.VRCConfigValues
|
||||
|
||||
// RegNotifyChangeKeyValue is not in JNA's standard Advapi32
|
||||
private interface RegistryNotify : Library {
|
||||
companion object {
|
||||
val INSTANCE: RegistryNotify = Native.load("Advapi32", RegistryNotify::class.java)
|
||||
const val REG_NOTIFY_CHANGE_LAST_SET = 0x00000004
|
||||
}
|
||||
|
||||
fun RegNotifyChangeKeyValue(
|
||||
hKey: WinReg.HKEY,
|
||||
bWatchSubtree: Boolean,
|
||||
dwNotifyFilter: Int,
|
||||
hEvent: WinNT.HANDLE,
|
||||
fAsynchronous: Boolean,
|
||||
): Int
|
||||
}
|
||||
|
||||
// VRChat writes 64-bit doubles as DWORD instead of QWORD, so we read raw bytes.
|
||||
internal suspend fun windowsGetQwordValue(path: String, key: String): Double? {
|
||||
val phkResult = WinReg.HKEYByReference()
|
||||
if (Advapi32.INSTANCE.RegOpenKeyEx(WinReg.HKEY_CURRENT_USER, path, 0, WinNT.KEY_READ, phkResult) != 0) {
|
||||
AppLogger.vrc.error("[VRChatRegEdit] 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) {
|
||||
AppLogger.vrc.error("[VRChatRegEdit] Cannot read registry key")
|
||||
return null
|
||||
}
|
||||
return lpData.getDouble(0)
|
||||
}
|
||||
|
||||
internal suspend fun windowsGetDwordValue(path: String, key: String): Int? = try {
|
||||
Advapi32Util.registryGetIntValue(WinReg.HKEY_CURRENT_USER, path, key)
|
||||
} catch (e: Exception) {
|
||||
AppLogger.vrc.error("[VRChatRegEdit] Error reading DWORD: ${e.message}")
|
||||
null
|
||||
}
|
||||
|
||||
internal suspend fun windowsGetVRChatKeys(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) {
|
||||
AppLogger.vrc.error("[VRChatRegEdit] Error reading VRC registry values: ${e.message}")
|
||||
}
|
||||
return keysMap
|
||||
}
|
||||
|
||||
internal fun windowsVRCConfigFlow(): Flow<VRCConfigValues?> = flow {
|
||||
while (true) {
|
||||
// Open key and register notification BEFORE reading to avoid race conditions:
|
||||
// any change that happens between registration and the read will trigger a re-read on next iteration
|
||||
val phkResult = WinReg.HKEYByReference()
|
||||
if (Advapi32.INSTANCE.RegOpenKeyEx(WinReg.HKEY_CURRENT_USER, VRC_REG_PATH, 0, WinNT.KEY_NOTIFY, phkResult) != 0) {
|
||||
// VRChat not installed
|
||||
emit(null)
|
||||
return@flow
|
||||
}
|
||||
|
||||
val hEvent = Kernel32.INSTANCE.CreateEvent(null, true, false, null)
|
||||
try {
|
||||
if (hEvent != null) {
|
||||
RegistryNotify.INSTANCE.RegNotifyChangeKeyValue(
|
||||
phkResult.value, false, RegistryNotify.REG_NOTIFY_CHANGE_LAST_SET, hEvent, true,
|
||||
)
|
||||
}
|
||||
|
||||
val keys = windowsGetVRChatKeys(VRC_REG_PATH)
|
||||
emit(if (keys.isEmpty()) null else buildVRCConfigValues(
|
||||
intValue = { key -> keys[key]?.let { windowsGetDwordValue(VRC_REG_PATH, it) } },
|
||||
doubleValue = { key -> keys[key]?.let { windowsGetQwordValue(VRC_REG_PATH, it) } },
|
||||
))
|
||||
|
||||
if (hEvent != null) {
|
||||
withContext(Dispatchers.IO) { Kernel32.INSTANCE.WaitForSingleObject(hEvent, WinBase.INFINITE) }
|
||||
}
|
||||
} finally {
|
||||
hEvent?.let { Kernel32.INSTANCE.CloseHandle(it) }
|
||||
Advapi32.INSTANCE.RegCloseKey(phkResult.value)
|
||||
}
|
||||
}
|
||||
}.flowOn(Dispatchers.IO)
|
||||
Reference in New Issue
Block a user