VRC settings (Non tested on windows)

This commit is contained in:
loucass003
2026-03-27 02:24:08 +01:00
parent ea92fb4c01
commit 8168e1366a
11 changed files with 495 additions and 9 deletions

View File

@@ -14,6 +14,7 @@ object AppLogger {
val hid = logger("HID")
val serial = logger("Serial")
val firmware = logger("Firmware")
val vrc = logger("VRChat")
init {
loggingConfiguration {

View File

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

View 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))
}
},
)

View 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)
}

View File

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

View File

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

View File

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

View File

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

View 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)

View File

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

View File

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